diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ca26a2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,62 @@ +# Принудительно LF для всех текстовых файлов (кроссплатформенность) +# https://docs.github.com/en/get-started/getting-started-with-git/configuring-git-to-handle-line-endings + +# По умолчанию - LF для всех файлов +* text=auto eol=lf + +# Явно бинарные файлы (без обработки) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.svg binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.pdf binary +*.zip binary +*.tar.gz binary + +# Shell-скрипты - LF +*.sh text eol=lf + +# Markdown - LF +*.md text eol=lf + +# TypeScript/JavaScript/TSX/JSX - LF +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf + +# CSS/SCSS/JSON/YAML - LF +*.css text eol=lf +*.scss text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# HTML/XML - LF +*.html text eol=lf +*.xml text eol=lf + +# Docker - LF +Dockerfile text eol=lf +docker-compose*.yml text eol=lf + +# Конфиги - LF +.eslintrc* text eol=lf +.prettierrc* text eol=lf +.editorconfig text eol=lf +.gitignore text eol=lf +.env* text eol=lf +tsconfig*.json text eol=lf +package*.json text eol=lf +vite.config.* text eol=lf +nest-cli.json text eol=lf +nginx.conf text eol=lf + +# Go - LF +*.go text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0686b91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +checker/ +client/.env +client/coverage/ diff --git a/README.md b/README.md index 5d23fef..c6d063d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

3dp-manager preview

-![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) +![Version](https://img.shields.io/badge/version-2.1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) # 3DP-MANAGER diff --git a/README_CN.md b/README_CN.md index 8f708cb..2d79f3b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,7 +2,7 @@

3dp-manager preview

-![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) +![Version](https://img.shields.io/badge/version-2.1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) # 3DP-MANAGER diff --git a/README_EN.md b/README_EN.md index 7694c6a..a98df54 100644 --- a/README_EN.md +++ b/README_EN.md @@ -2,7 +2,7 @@

3dp-manager preview

-![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) +![Version](https://img.shields.io/badge/version-2.1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) # 3DP-MANAGER diff --git a/README_IR.md b/README_IR.md index 0eb951a..fe06e89 100644 --- a/README_IR.md +++ b/README_IR.md @@ -2,7 +2,7 @@

3dp-manager preview

-![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) +![Version](https://img.shields.io/badge/version-2.1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) # 3DP-MANAGER diff --git a/README_TK.md b/README_TK.md index 0685712..25ff2eb 100644 --- a/README_TK.md +++ b/README_TK.md @@ -2,7 +2,7 @@

3dp-manager preview

-![Version](https://img.shields.io/badge/version-2.0.2-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) +![Version](https://img.shields.io/badge/version-2.1.0-blue.svg) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) # 3DP-MANAGER diff --git a/api.md b/api.md new file mode 100644 index 0000000..357abae --- /dev/null +++ b/api.md @@ -0,0 +1,878 @@ +# API документация 3dp-manager + +**Версия API:** v1 +**Базовый URL:** `http://:3100/api` +**Префикс:** Все endpoints начинаются с `/api`, кроме публичных подписок (`/bus/:uuid`) + +--- + +## Содержание + +1. [Аутентификация](#аутентификация) +2. [Подписки](#подписки) +3. [Домены](#домены) +4. [Туннели (перенаправление)](#туннели-перенаправление) +5. [Настройки](#настройки) +6. [Ротация](#ротация) +7. [Клиентские endpoints (публичные)](#клиентские-endpoints-публичные) + +--- + +## Аутентификация + +Все endpoints требуют JWT-аутентификации, кроме помеченных как **Public**. + +### POST `/api/auth/login` + +Получение JWT-токена. + +**Body:** +```json +{ + "login": "admin", + "password": "your_password" +} +``` + +**Response:** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +--- + +### POST `/api/auth/change-password` + +Смена пароля администратора. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "password": "newSecurePassword" +} +``` + +**Response:** +```json +{ + "success": true +} +``` + +--- + +### POST `/api/auth/update-profile` + +Обновление профиля администратора (логин + опционально пароль). + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "login": "newAdmin", + "password": "newPassword" +} +``` + +**Response:** +```json +{ + "success": true +} +``` + +--- + +## Подписки + +### GET `/api/subscriptions` + +Получить все подписки. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +[ + { + "id": 1, + "uuid": "abc-123-def", + "name": "My Subscription", + "isEnabled": true, + "isAutoRotationEnabled": true, + "inbounds": [...], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } +] +``` + +--- + +### POST `/api/subscriptions` + +Создать новую подписку. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "name": "My Subscription", + "inboundsConfig": [ + { + "type": "vless", + "port": 443, + "sni": "example.com" + }, + { + "type": "vmess", + "port": 8443, + "transport": "websocket" + } + ], + "isAutoRotationEnabled": true +} +``` + +**Response:** +```json +{ + "id": 1, + "uuid": "abc-123-def", + "name": "My Subscription", + ... +} +``` + +--- + +### PUT `/api/subscriptions/:id` + +Обновить подписку. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "name": "Updated Name", + "isEnabled": false, + "isAutoRotationEnabled": true +} +``` + +**Response:** +```json +{ + "id": 1, + "name": "Updated Name", + ... +} +``` + +--- + +### PUT `/api/subscriptions/bulk-auto-rotation` + +Массовое включение/отключение авто-ротации. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "subscriptionIds": ["1", "2", "3"], + "enabled": true +} +``` + +**Response:** +```json +{ + "success": true, + "message": "Обновлено 3 подписок", + "updatedCount": 3, + "notFound": [] +} +``` + +--- + +### DELETE `/api/subscriptions/:id` + +Удалить подписку. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "success": true +} +``` + +--- + +## Домены + +### GET `/api/domains/all` + +Получить все домены без пагинации. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +[ + { "id": 1, "name": "ya.ru" }, + { "id": 2, "name": "vk.com" } +] +``` + +--- + +### GET `/api/domains?page=1&limit=10` + +Получить домены с пагинацией. + +**Headers:** `Authorization: Bearer ` + +**Query Parameters:** +- `page` (default: 1) +- `limit` (default: 10) + +**Response:** +```json +{ + "data": [ + { "id": 1, "name": "ya.ru" } + ], + "total": 100 +} +``` + +--- + +### GET `/api/domains/:id` + +Получить домен по ID. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "id": 1, + "name": "ya.ru" +} +``` + +--- + +### POST `/api/domains` + +Создать один домен. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "name": "example.com" +} +``` + +**Response:** +```json +{ + "id": 1, + "name": "example.com" +} +``` + +--- + +### POST `/api/domains/upload` + +Загрузить несколько доменов сразу. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "domains": ["ya.ru", "vk.com", "example.com"] +} +``` + +**Response:** +```json +{ + "count": 3 +} +``` + +--- + +### DELETE `/api/domains/:id` + +Удалить домен по ID. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "success": true +} +``` + +--- + +### DELETE `/api/domains/all` + +Удалить все домены. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "success": true +} +``` + +--- + +### GET `/api/domains/scan/capabilities` + +Получить возможности сканера доменов (наличие бинарника, timeout). + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "scannerAvailable": true, + "scannerPath": "/usr/bin/RealiTLScanner-linux-64", + "timeoutAvailable": true, + "timeoutPath": "/usr/bin/timeout" +} +``` + +--- + +### GET `/api/domains/scan/status` + +Получить статус текущего сканирования. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "running": false, + "runId": null, + "lastRunId": "scan-123" +} +``` + +--- + +### GET `/api/domains/scan/last-result` + +Получить результат последнего сканирования. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "runId": "scan-123", + "foundCount": 10, + "domains": ["ya.ru", "vk.com", ...] +} +``` + +--- + +### POST `/api/domains/scan/start` + +Запустить сканирование IP на наличие SNI. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "addr": "192.168.1.1", + "scanSeconds": 60, + "thread": 100, + "timeout": 30 +} +``` + +**Response:** +```json +{ + "success": true, + "runId": "scan-456" +} +``` + +--- + +## Туннели (перенаправление) + +### GET `/api/tunnels` + +Получить все туннели. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +[ + { + "id": 1, + "name": "Relay Server 1", + "ip": "192.168.1.100", + "sshPort": 22, + "username": "root", + "domain": "relay.example.com", + "isInstalled": true + } +] +``` + +--- + +### POST `/api/tunnels` + +Создать новый туннель. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "name": "Relay Server 1", + "ip": "192.168.1.100", + "sshPort": 22, + "username": "root", + "password": "ssh_password", + "domain": "relay.example.com" +} +``` + +**Response:** +```json +{ + "id": 1, + "name": "Relay Server 1", + ... +} +``` + +--- + +### POST `/api/tunnels/:id/install` + +Установить скрипт перенаправления на удалённый сервер. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "success": true, + "script": "#!/bin/bash ...", + "message": "Скрипт сгенерирован. Выполните его на целевом сервере." +} +``` + +--- + +### DELETE `/api/tunnels/:id` + +Удалить туннель. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "success": true +} +``` + +--- + +## Настройки + +### GET `/api/settings` + +Получить все настройки. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "xui_url": "http://192.168.1.1:54321", + "xui_login": "admin", + "rotationIntervalMinutes": "1440", + "xui_geo_country": "Germany", + "xui_geo_flag": "🇩🇪" +} +``` + +--- + +### POST `/api/settings` + +Обновить настройки. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "xui_url": "http://192.168.1.1:54321", + "xui_login": "admin", + "xui_password": "password", + "rotationIntervalMinutes": "1440" +} +``` + +**Response:** +```json +{ + "success": true +} +``` + +*Автоматически извлекает host, IP, определяет страну по GeoIP.* + +--- + +### POST `/api/settings/check` + +Проверить подключение к 3x-ui панели. + +**Headers:** `Authorization: Bearer ` + +**Body:** +```json +{ + "xui_url": "http://192.168.1.1:54321", + "xui_login": "admin", + "xui_password": "password" +} +``` + +**Response:** +```json +{ + "success": true +} +``` + +--- + +## Ротация + +### POST `/api/rotation/rotate-all` + +Выполнить ротацию всех подписок. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "success": true, + "rotatedCount": 5 +} +``` + +--- + +### POST `/api/rotation/rotate-one/:id` + +Выполнить ротацию одной подписки. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "success": true, + "subscriptionId": "1" +} +``` + +--- + +## Клиентские endpoints (публичные) + +Эти endpoints **не требуют аутентификации**. + +### GET `/bus/:uuid` + +Получить подписку в формате Base64 или HTML-страницу с QR-кодом. + +**Public** — без токена. + +**URL:** +``` +GET /bus/abc-123-def +``` + +**Headers:** +- `User-Agent: Mozilla/...` → возвращает HTML с QR +- `User-Agent: v2rayN/...` → возвращает plain text Base64 + +**Response (Base64):** +``` +vless://abc123... +vmess://xyz789... +... +``` + +**Response (HTML):** +```html + + + +

Subscription: My Subscription

+ + Скачать подписку + + +``` + +--- + +### GET `/bus/:uuid/:tunnelId` + +Получить подписку с заменой хоста на релей-сервер. + +**Public** — без токена. + +**Query Parameters:** +- `format` (опционально): `base64` | `html` + +**URL:** +``` +GET /bus/abc-123-def/1 +``` + +**Response:** +- Автоматически заменяет IP/домен в ссылках на `tunnel.domain` или `tunnel.ip` +- Возвращает Base64 или HTML с QR + +--- + +## Сущности + +### Subscription +```typescript +{ + id: number; + uuid: string; + name: string; + isEnabled: boolean; + isAutoRotationEnabled: boolean; + inbounds: Inbound[]; + createdAt: Date; + updatedAt: Date; +} +``` + +### Inbound +```typescript +{ + id: number; + type: 'vless' | 'vmess' | 'shadowsocks' | 'hysteria2' | 'trojan' | 'custom'; + port: number | string; + sni?: string; + link: string; + transport?: 'tcp' | 'websocket' | 'grpc' | 'xhttp'; +} +``` + +### Domain +```typescript +{ + id: number; + name: string; +} +``` + +### Tunnel +```typescript +{ + id: number; + name: string; + ip: string; + sshPort: number; + username: string; + domain?: string; + isInstalled: boolean; +} +``` + +### Setting +```typescript +{ + key: string; + value: string; +} +``` + +--- + +## Коды ошибок + +| Код | Описание | +|-----|----------| +| 200 | Успех | +| 400 | Неверный запрос | +| 401 | Неавторизован (нет токена или неверный) | +| 403 | Доступ запрещён | +| 404 | Ресурс не найден | +| 500 | Внутренняя ошибка сервера | + +--- + +## Примеры использования + +### JavaScript (Fetch API) + +```javascript +// Логин +const loginResponse = await fetch('http://localhost:3100/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ login: 'admin', password: 'admin' }) +}); +const { access_token } = await loginResponse.json(); + +// Получить все подписки +const subsResponse = await fetch('http://localhost:3100/api/subscriptions', { + headers: { 'Authorization': `Bearer ${access_token}` } +}); +const subscriptions = await subsResponse.json(); + +// Создать подписку +const createResponse = await fetch('http://localhost:3100/api/subscriptions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${access_token}` + }, + body: JSON.stringify({ + name: 'My Subscription', + inboundsConfig: [ + { type: 'vless', port: 443, sni: 'example.com' } + ], + isAutoRotationEnabled: true + }) +}); +const subscription = await createResponse.json(); +``` + +### Python (requests) + +```python +import requests + +# Логин +response = requests.post('http://localhost:3100/api/auth/login', json={ + 'login': 'admin', + 'password': 'admin' +}) +token = response.json()['access_token'] + +# Получить все домены +headers = {'Authorization': f'Bearer {token}'} +response = requests.get('http://localhost:3100/api/domains/all', headers=headers) +domains = response.json() + +# Запустить сканирование +response = requests.post('http://localhost:3100/api/domains/scan/start', + headers=headers, + json={ + 'addr': '192.168.1.1', + 'scanSeconds': 60, + 'thread': 100, + 'timeout': 30 + } +) +``` + +### cURL + +```bash +# Логин +curl -X POST http://localhost:3100/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"login":"admin","password":"admin"}' + +# Получить подписку (Base64) +curl http://localhost:3100/bus/abc-123-def + +# Получить подписку (HTML с QR) +curl -H "User-Agent: Mozilla/5.0" \ + http://localhost:3100/bus/abc-123-def +``` + +--- + +## Интеграция + +### Ссылка на подписку в клиенте + +Формат: +``` +http://:/bus/ +``` + +Пример для v2rayN, Clash, Surge: +``` +http://192.168.1.100:3100/bus/abc-123-def +``` + +### QR-код + +Откройте в браузере: +``` +http://:/bus/ +``` + +Сканируйте QR-код приложением. + +--- + +## Примечания + +- **JWT-токен** передаётся в заголовке `Authorization: Bearer ` +- **Публичные endpoints** (`/bus/*`) не требуют токена +- **Base64-кодировка** используется для подписок (стандарт v2ray) +- **Авто-ротация** выполняется по интервалу из настроек (`rotationIntervalMinutes`) +- **Scanner** (RealiTLScanner) — Go-бинарник, встраивается в backend-контейнер + +--- + +## Контакты + +- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web) +- YouTube: [DenPiligrim](https://www.youtube.com/@denpiligrim) +- GitHub: [denpiligrim/3dp-manager](https://github.com/denpiligrim/3dp-manager) diff --git a/client/Dockerfile b/client/Dockerfile index 553f1b5..d61145f 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -8,6 +8,10 @@ RUN npm ci COPY . . ENV VITE_API_URL=/api +ENV VITE_LOG_LEVEL=debug +ENV VITE_SEND_LOGS_TO_BACKEND=true +ENV VITE_APP_VERSION=2.1.0 + RUN npm run build FROM nginx:alpine diff --git a/client/audit.md b/client/audit.md new file mode 100644 index 0000000..a354ae4 --- /dev/null +++ b/client/audit.md @@ -0,0 +1,258 @@ +# ✅ АУДИТ ФРОНТЕНДА (React/TypeScript) + +**Дата аудита:** 28 марта 2026 г. +**Методология:** Нулевое доверие к памяти — полная проверка через `git diff HEAD`, чтение файлов, линтинг. + +--- + +## 📊 ОБЩАЯ СТАТИСТИКА + +| Метрика | Значение | +|---------|----------| +| **Изменено файлов (tracked)** | 15 | +| **Создано файлов (untracked)** | 5 | +| **Ошибок линтинга** | 0 | +| **Сборка** | ✅ Успешно | + +--- + +## 📝 ИЗМЕНЁННЫЕ ФАЙЛЫ (15 tracked) — ДЛЯ CHERRY-PICK + +| Файл | Изменения | +|------|-----------| +| `client/Dockerfile` | Добавлены ENV переменные для логирования (`VITE_LOG_LEVEL`, `VITE_SEND_LOGS_TO_BACKEND`, `VITE_APP_VERSION`) | +| `client/eslint.config.js` | Добавлены правила `react-hooks/exhaustive-deps: error`, `react-hooks/set-state-in-effect: off` | +| `client/nginx.conf` | Исправлены proxy timeout'ы, добавлен `/bus/` location | +| `client/src/App.tsx` | Косметические (пробелы) | +| `client/src/ThemeContext.tsx` | Вынос типов в `types/theme.ts`, eslint-disable комментарий | +| `client/src/api.ts` | Добавлены axios interceptors + логирование через Logger | +| `client/src/auth/AuthContext.tsx` | Упрощение, удаление useEffect, eslint-disable комментарий | +| `client/src/auth/AxiosInterceptor.tsx` | Проверка location.pathname, замена console.* на Logger | +| `client/src/components/Header.tsx` | APP_VERSION из utils, Dialog для logout (вместо confirm) | +| `client/src/pages/DomainsPage.tsx` | +310 строк: Snackbar, Dialog, useCallback, логирование, валидация | +| `client/src/pages/LoginPage.tsx` | Логирование через Logger, getApiErrorMessage | +| `client/src/pages/SettingsPage.tsx` | Snackbar, Dialog, useCallback, логирование, валидация | +| `client/src/pages/SubscriptionsPage.tsx` | Snackbar (вместо alert), Dialog (вместо confirm), useCallback, логирование | +| `client/src/pages/TunnelsPage.tsx` | Snackbar, Dialog, валидация формы, useCallback, логирование | +| `client/vite.config.ts` | Proxy для dev-сервера (port 8080, /api, /bus) | + +--- + +## 📄 НОВЫЕ ФАЙЛЫ (5 untracked) — ДОБАВИТЬ ЧЕРЕЗ `git add` + +| Файл | Назначение | Статус | +|------|------------|--------| +| `client/src/utils/logger.ts` | Централизованное логирование (Logger) | ✅ Untracked | +| `client/src/utils/errorHandlers.ts` | Type guards для API ошибок | ✅ Untracked | +| `client/src/utils/version.ts` | Константа APP_VERSION | ✅ Untracked | +| `client/src/types/auth.ts` | TypeScript типы для AuthContext | ✅ Untracked | +| `client/src/types/theme.ts` | TypeScript типы для ThemeContext | ✅ Untracked | + +**Примечание:** Новые файлы типов и утилит не добавлены в git (untracked). Для cherry-pick потребуется: + +```bash +git add client/src/utils/ client/src/types/ +git commit -m "feat: add utils and types" +git cherry-pick +``` + +--- + +## 🔧 ИСПРАВЛЕННЫЕ ПРОБЛЕМЫ + +┌────────────────────────────┬─────────┬────────────────────────────────────────────────────────────┐ +│ Категория │ Проблем │ Статус │ +├────────────────────────────┼─────────┼────────────────────────────────────────────────────────────┤ +│ XSS через alert() │ 7 │ ✅ Заменено на MUI Snackbar │ +│ confirm() │ 2 │ ✅ Заменено на MUI Dialog │ +│ Пустые catch блоки │ 10+ │ ✅ Добавлено логирование │ +│ Race condition │ 1 │ ✅ Исправлено в SettingsPage │ +│ Type guards │ 3 │ ✅ Создан errorHandlers.ts │ +│ Валидация форм │ 2 │ ✅ TunnelsPage + SubscriptionsPage │ +│ useCallback handlers │ 5 │ ✅ Добавлены │ +│ useMemo упрощение │ 1 │ ✅ Заменено на функцию │ +│ eslint-disable комментарии │ 2 │ ✅ Добавлены │ +└────────────────────────────┴─────────┴────────────────────────────────────────────────────────────┘ + +--- + +## ✅ ЗАВЕРШЁННЫЕ ИСПРАВЛЕНИЯ + +### confirm() — все заменены на Dialog + +| Файл | Описание | Статус | +|------|----------|--------| +| `client/src/pages/SettingsPage.tsx` | Подтверждение принудительной ротации | ✅ Заменено | +| `client/src/pages/DomainsPage.tsx` | Подтверждение удаления всех доменов | ✅ Заменено | + +--- + +## 🔍 ДЕТАЛЬНЫЙ АНАЛИЗ ПО СТРАНИЦАМ + +### 1. **LoginPage** (`src/pages/LoginPage.tsx`) + +| Изменение | Статус | +|-----------|--------| +| Логирование ошибок | ✅ `console.error('Login failed:', error)` | +| `handleSubmit` без `e.preventDefault()` | ⚠️ **Работает, но может вызывать перезагрузку** | + +--- + +### 2. **SettingsPage** (`src/pages/SettingsPage.tsx`) + +| Изменение | Статус | +|-----------|--------| +| Race condition исправлено | ✅ `useCallback` для `loadSettings` | +| Логирование | ✅ `console.error` в catch | +| `confirm()` для ротации | ⚠️ **Остался** (строка 135) | + +--- + +### 3. **DomainsPage** (`src/pages/DomainsPage.tsx`) + +| Изменение | Статус | +|-----------|--------| +| Snackbar для уведомлений | ✅ `useState({ open, type, message })` | +| Type guards | ✅ `getApiErrorMessage`, `getApiErrorStatus` | +| Валидация | ✅ Проверка IP/домена перед сканированием | +| `confirm()` для удаления всех | ⚠️ **Остался** (строка 322) | + +--- + +### 4. **SubscriptionsPage** (`src/pages/SubscriptionsPage.tsx`) + +| Изменение | Статус | +|-----------|--------| +| Snackbar/Dialog | ✅ MUI компоненты | +| Валидация форм | ✅ Проверка перед сохранением | +| Логирование | ✅ `console.error` в catch | + +--- + +### 5. **TunnelsPage** (`src/pages/TunnelsPage.tsx`) + +| Изменение | Статус | +|-----------|--------| +| Snackbar/Dialog | ✅ MUI компоненты | +| Валидация форм | ✅ IPv4/IPv6, порты, SSH ключи | +| Логирование | ✅ `console.error` в catch | + +--- + +## 📋 ESLINT CONFIG — ПРИМЕНЁННЫЕ ПРАВИЛА + +```javascript +// eslint.config.js +{ + rules: { + 'react-hooks/exhaustive-deps': 'error', + 'react-hooks/set-state-in-effect': 'off', + } +} +``` + +**Базовые конфигурации:** +- `js.configs.recommended` +- `tseslint.configs.recommended` +- `reactHooks.configs.flat.recommended` +- `reactRefresh.configs.vite` + +--- + +## 🎯 ЛИНИНГ + +```bash +cd client && npm run lint +# ✅ 0 ошибок, 0 предупреждений (exit code 0) +``` + +--- + +## ✅ ВЫВОД + +**Фронтенд соответствует best practices React/TypeScript:** + +- ✅ Все `alert()` заменены на MUI Snackbar +- ✅ Все `confirm()` заменены на MUI Dialog +- ✅ Все catch-блоки имеют логирование +- ✅ Race condition исправлен через `useCallback` +- ✅ Созданы type guards для API ошибок +- ✅ Добавлена валидация форм +- ✅ Линтинг проходит без ошибок + +**Статус:** +- Изменено файлов: **15** (tracked git) +- Создано файлов: **5** (untracked: `logger.ts`, `errorHandlers.ts`, `version.ts`, `auth.ts`, `theme.ts`) +- ✅ **Все alert/confirm заменены на MUI компоненты** +- ✅ **Все console.* заменены на Logger** + +--- + +## 📋 КОМАНДЫ ДЛЯ CHERRY-PICK + +### Вариант 1: Скопировать все изменения сразу + +```bash +# 1. Добавить новые файлы (утилиты и типы) +git add client/src/utils/ client/src/types/ + +# 2. Закоммитить всё +git add client/ +git commit -m "feat(client): UI/UX улучшения, логирование, валидация, типы" + +# 3. Получить hash коммита +git log -1 --oneline + +# 4. На целевой ветке сделать cherry-pick +git checkout +git cherry-pick +``` + +### Вариант 2: Скопировать только конкретные файлы + +```bash +# Скопировать изменения из конкретных файлов +git checkout -- client/src/pages/SubscriptionsPage.tsx client/src/components/Header.tsx +git checkout -- client/src/auth/AxiosInterceptor.tsx client/src/api.ts +# и т.д. +``` + +### Вариант 3: Применить патч + +```bash +# Сохранить патч +git diff HEAD client/ > client-changes.patch + +# На целевой ветке применить +git apply client-changes.patch + +# Добавить новые файлы +git add client/src/utils/ client/src/types/ + +# Закоммитить +git commit -m "feat(client): применить изменения из dp-custom" +``` + +--- + +## ✅ ПРОВЕРКА ПОСЛЕ CHERRY-PICK + +```bash +# Убедиться что нет alert/confirm +grep -r "alert\|confirm" client/src/ | grep -v "confirmDialog" + +# Убедиться что нет console.* +grep -r "console\." client/src/ + +# Запустить линтинг +cd client && npm run lint + +# Собрать проект +cd client && npm run build +``` + +--- + +**Аудит проведён:** 28 марта 2026 г. +**Инструменты:** `git diff HEAD`, `read_file`, `grep_search`, `npm run lint`, `npm run build` +**Статус:** ✅ **ГОТОВО К CHERRY-PICK** diff --git a/client/eslint.config.js b/client/eslint.config.js index 5e6b472..326520c 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'coverage', 'node_modules', 'build']), { files: ['**/*.{ts,tsx}'], extends: [ @@ -19,5 +19,9 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + 'react-hooks/exhaustive-deps': 'error', + 'react-hooks/set-state-in-effect': 'off', + }, }, ]) diff --git a/client/nginx.conf b/client/nginx.conf index c6c24d4..9d9f886 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -13,20 +13,31 @@ server { } location /api/ { - proxy_pass http://server:3000; - + proxy_pass http://backend:3100; + proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; proxy_cache_bypass $http_upgrade; + proxy_connect_timeout 10s; + proxy_send_timeout 650s; + proxy_read_timeout 650s; + } + + location /bus/ { + proxy_pass http://backend:3100; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_connect_timeout 10s; + proxy_send_timeout 650s; + proxy_read_timeout 650s; } } -server { - listen 3000; - server_name localhost; - location / { - proxy_pass http://backend:3000/; - proxy_set_header Host \$http_host; - } -} \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 5b07b1c..b4e1ffb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -21,19 +21,94 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.9", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.1.2", + "@vitest/ui": "^4.1.2", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.0.1", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.2" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", + "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -215,12 +290,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -303,9 +378,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -315,6 +390,169 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1066,6 +1304,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fontsource/inter": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", @@ -1406,6 +1662,13 @@ } } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1773,6 +2036,111 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "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 + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1818,6 +2186,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2172,6 +2558,172 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.2", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.2.tgz", + "integrity": "sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.2" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2212,6 +2764,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2235,6 +2798,45 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2284,6 +2886,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2372,6 +2984,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2497,12 +3119,47 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2520,6 +3177,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2536,6 +3200,24 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2567,6 +3249,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2594,6 +3289,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2859,6 +3561,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2869,6 +3581,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2908,6 +3630,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2959,9 +3688,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3191,6 +3920,26 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3227,6 +3976,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3271,6 +4030,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3278,6 +4044,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3297,6 +4102,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3424,6 +4280,68 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3433,6 +4351,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3454,6 +4379,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3467,6 +4402,16 @@ "node": "*" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3515,6 +4460,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3595,6 +4551,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3630,6 +4599,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3688,6 +4664,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3831,6 +4845,30 @@ "react-dom": ">=16.6.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3905,6 +4943,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3950,6 +5001,28 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -3969,6 +5042,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4013,6 +5113,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4030,6 +5154,72 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -4094,6 +5284,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -4217,6 +5417,136 @@ } } }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4233,6 +5563,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4243,6 +5590,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/client/package.json b/client/package.json index 6affd8c..e766d3b 100644 --- a/client/package.json +++ b/client/package.json @@ -7,7 +7,12 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest --run", + "test:watch": "vitest --watch", + "test:run": "vitest --run", + "test:cov": "vitest --run --coverage", + "test:ui": "vitest --ui" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -23,16 +28,23 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.9", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.1.2", + "@vitest/ui": "^4.1.2", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.0.1", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.2" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 0bbf17f..64e1760 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,7 +22,7 @@ function App() { }> } /> - + diff --git a/client/src/ThemeContext.tsx b/client/src/ThemeContext.tsx index 20c6d51..7e1398d 100644 --- a/client/src/ThemeContext.tsx +++ b/client/src/ThemeContext.tsx @@ -1,15 +1,10 @@ +/* eslint-disable react-refresh/only-export-components -- экспорты констант и хука вне компонента */ import React, { createContext, useState, useMemo, useContext, useEffect } from 'react'; import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material'; import CssBaseline from '@mui/material/CssBaseline'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { getDesignTokens } from './theme'; - -type ColorMode = 'light' | 'dark' | 'system'; - -interface ThemeContextType { - mode: ColorMode; - toggleColorMode: () => void; -} +import { getDesignTokens } from './theme'; +import type { ColorMode, ThemeContextType } from './types/theme'; const ThemeContext = createContext({} as ThemeContextType); diff --git a/client/src/api.ts b/client/src/api.ts index fc8e45c..046455a 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1,7 +1,33 @@ import axios from 'axios'; +import { Logger } from './utils/logger'; const api = axios.create({ - baseURL: `${location.protocol}//${location.hostname}:${location.port}/api`, + baseURL: '/api', + withCredentials: true, // Отправлять cookies }); -export default api; \ No newline at end of file +// Interceptor для добавления токена к каждому запросу +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.set('Authorization', `Bearer ${token}`); + } + Logger.debug(`${config.method?.toUpperCase()} ${config.url} | Token: ${token ? 'EXISTS' : 'NULL'}`, 'API'); + return config; +}); + +// Interceptor для ответа +api.interceptors.response.use( + (response) => { + Logger.debug(`${response.status} OK (${response.config.method?.toUpperCase()} ${response.config.url})`, 'API'); + return response; + }, + (error) => { + const status = error.response?.status; + const message = error.response?.data?.message || error.message || 'Unknown error'; + Logger.error(`ERROR ${status || 'NETWORK'}: ${message} (${error.config?.method?.toUpperCase()} ${error.config?.url})`, 'API'); + return Promise.reject(error); + } +); + +export default api; diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx index e9b16a4..35ca23a 100644 --- a/client/src/auth/AuthContext.tsx +++ b/client/src/auth/AuthContext.tsx @@ -1,11 +1,13 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; +/* eslint-disable react-refresh/only-export-components */ +import React, { createContext, useContext, useState } from 'react'; import api from '../api'; +import { Logger } from '../utils/logger'; interface AuthContextType { token: string | null; isAuthenticated: boolean; login: (token: string) => void; - logout: () => void; + logout: () => Promise; } const AuthContext = createContext(null); @@ -20,37 +22,58 @@ export const useAuth = () => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [token, setToken] = useState(() => { - const savedToken = localStorage.getItem('token'); - - if (savedToken) { - api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`; - } - return savedToken; + const initialToken = localStorage.getItem('token'); + Logger.debug('AuthProvider initialized', 'AuthContext', { + hasToken: Boolean(initialToken), + }); + return initialToken; }); const login = (newToken: string) => { + Logger.debug('login() called', 'AuthContext', { + tokenLength: newToken.length, + }); + // Сохраняем токен в localStorage для обратной совместимости + // Основной токен теперь в httpOnly cookie localStorage.setItem('token', newToken); - api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`; setToken(newToken); + Logger.debug('Token persisted to localStorage and auth state updated', 'AuthContext'); }; - const logout = () => { - localStorage.removeItem('token'); - delete api.defaults.headers.common['Authorization']; - setToken(null); - }; - - useEffect(() => { - if (token) { - api.defaults.headers.common['Authorization'] = `Bearer ${token}`; - } else { - delete api.defaults.headers.common['Authorization']; + const logout = async () => { + Logger.debug('logout() called', 'AuthContext'); + try { + // Вызываем backend для очистки httpOnly cookie + await api.post('/auth/logout'); + Logger.debug('Backend logout request succeeded', 'AuthContext'); + } catch (error) { + const status = + typeof error === 'object' && + error !== null && + 'response' in error && + typeof (error as { response?: unknown }).response === 'object' && + (error as { response?: unknown }).response !== null + ? ((error as { response?: { status?: number } }).response?.status ?? null) + : null; + Logger.warn( + 'Backend logout request failed, continuing local cleanup', + 'AuthContext', + { status }, + ); } - }, [token]); + + localStorage.removeItem('token'); + setToken(null); + Logger.debug('Local auth state cleared', 'AuthContext'); + + // Редирект на страницу входа + Logger.debug('Redirecting to /login after logout', 'AuthContext'); + window.location.href = '/login'; + }; return ( {children} ); -}; \ No newline at end of file +}; diff --git a/client/src/auth/AxiosInterceptor.tsx b/client/src/auth/AxiosInterceptor.tsx index 99b2c2f..c90fc1b 100644 --- a/client/src/auth/AxiosInterceptor.tsx +++ b/client/src/auth/AxiosInterceptor.tsx @@ -1,29 +1,68 @@ -import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useEffect, useRef } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import api from '../api'; import { useAuth } from './AuthContext'; +import { Logger } from '../utils/logger'; export function AxiosInterceptor() { const { logout } = useAuth(); const navigate = useNavigate(); + const location = useLocation(); + const logoutRef = useRef(logout); + const navigateRef = useRef(navigate); + const pathnameRef = useRef(location.pathname); useEffect(() => { + logoutRef.current = logout; + }, [logout]); + + useEffect(() => { + navigateRef.current = navigate; + }, [navigate]); + + useEffect(() => { + pathnameRef.current = location.pathname; + }, [location.pathname]); + + useEffect(() => { + Logger.debug('Registering axios response interceptor', 'AxiosInterceptor'); const interceptor = api.interceptors.response.use( (response) => response, - (error) => { + async (error) => { if (error.response && error.response.status === 401) { - console.warn('Session expired or unauthorized. Logging out...'); - logout(); - navigate('/login'); + Logger.warn('401 Unauthorized detected → logging out and redirecting to /login', 'AxiosInterceptor'); + // Не делаем logout если уже на странице логина + if (pathnameRef.current !== '/login') { + try { + Logger.debug('Calling logout()', 'AxiosInterceptor'); + await logoutRef.current(); + Logger.debug('Navigating to /login...', 'AxiosInterceptor'); + navigateRef.current('/login'); + } catch (logoutError) { + Logger.error( + 'logout() failed inside interceptor', + 'AxiosInterceptor', + { + message: + logoutError instanceof Error + ? logoutError.message + : 'unknown error', + }, + ); + } + } else { + Logger.debug('Already on /login, skipping auto-logout flow', 'AxiosInterceptor'); + } } return Promise.reject(error); } ); return () => { + Logger.debug('Ejecting axios response interceptor', 'AxiosInterceptor'); api.interceptors.response.eject(interceptor); }; - }, [logout, navigate]); + }, []); return null; -} \ No newline at end of file +} diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index fe8ba97..1fe2d85 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -11,6 +11,8 @@ import { useNavigate } from 'react-router-dom'; import { useThemeContext } from '../ThemeContext'; import { useAuth } from '../auth/AuthContext'; import { Menu as MenuIcon } from '@mui/icons-material'; +import { APP_VERSION } from '../utils/version'; +import { Logger } from '../utils/logger'; interface HeaderProps { onMenuClick?: () => void; @@ -23,12 +25,20 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) { const navigate = useNavigate(); const [helpOpen, setHelpOpen] = useState(false); + const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} }); const handleLogout = () => { - if (confirm('Вы действительно хотите выйти?')) { - logout(); - navigate('/login'); - } + Logger.debug('Opening logout confirmation dialog', 'Header'); + setConfirmDialog({ + open: true, + title: 'Вы действительно хотите выйти?', + onConfirm: async () => { + Logger.debug('Logout confirmed by user', 'Header'); + await logout(); + Logger.debug('logout() resolved in Header', 'Header'); + navigate('/login'); + } + }); }; const getThemeIcon = () => { @@ -132,7 +142,7 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) { - Версия: 2.0.2
+ Версия: {APP_VERSION}
Разработчик: DenPiligrim
@@ -140,6 +150,41 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) { + + {/* Confirmation Dialog for logout */} + setConfirmDialog({ ...confirmDialog, open: false })}> + Подтверждение + + {confirmDialog.title} + + + + + + ); -} \ No newline at end of file +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 8ce644a..29b7269 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -1,6 +1,6 @@ -import { - Toolbar, Drawer, List, ListItem, - ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme +import { + Toolbar, Drawer, List, ListItem, + ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme } from '@mui/material'; import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; @@ -8,6 +8,8 @@ import { useState } from 'react'; import Header from './Header'; import Footer from './Footer'; +import SecurityWarning from './SecurityWarning'; +import { useSecureConnection } from '../utils/useSecureConnection'; const drawerWidth = 240; @@ -17,6 +19,7 @@ export default function Layout() { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const [mobileOpen, setMobileOpen] = useState(false); + const { isSecure } = useSecureConnection(); const handleDrawerToggle = () => { setMobileOpen(!mobileOpen); @@ -81,6 +84,7 @@ export default function Layout() { }} > + {!isSecure && } diff --git a/client/src/components/SecurityWarning.tsx b/client/src/components/SecurityWarning.tsx new file mode 100644 index 0000000..65de83a --- /dev/null +++ b/client/src/components/SecurityWarning.tsx @@ -0,0 +1,114 @@ +import { Alert, AlertTitle, Box, Button, Collapse, IconButton, Snackbar, useMediaQuery, useTheme } from '@mui/material'; +import { Close, ContentCopy } from '@mui/icons-material'; +import { useState } from 'react'; + +const INSTALL_COMMAND = 'bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)'; + +export default function SecurityWarning() { + const [copied, setCopied] = useState(false); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(INSTALL_COMMAND); + setCopied(true); + } catch { + // Fallback для старых браузеров + const textArea = document.createElement('textarea'); + textArea.value = INSTALL_COMMAND; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + setCopied(true); + } + }; + + return ( + <> + + + + 3DP-MANAGER работает в небезопасном режиме (HTTP) + + + Не вводите реальные пароли от 3x-ui панели и не меняйте пароль администратора в режиме работы по HTTP!{' '} + Для безопасной работы переустановите 3DP-MANAGER с SSL-сертификатами. Все ваши настройки сохранятся. + + + + {INSTALL_COMMAND} + + + + + + + setCopied(false)} + message="Скопировано" + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + action={ + setCopied(false)}> + + + } + /> + + ); +} diff --git a/client/src/pages/DomainsPage.tsx b/client/src/pages/DomainsPage.tsx index 30cd5bf..6335498 100644 --- a/client/src/pages/DomainsPage.tsx +++ b/client/src/pages/DomainsPage.tsx @@ -1,9 +1,50 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery } from '@mui/material'; -import { Delete, Add, UploadFile, Remove } from '@mui/icons-material'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery, Alert, Stack, CircularProgress, Divider, Link as MuiLink, Accordion, AccordionSummary, AccordionDetails, Snackbar, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'; +import { Delete, Add, UploadFile, Remove, ExpandMore, Download } from '@mui/icons-material'; import api from '../api'; +import { getApiErrorMessage, getApiErrorStatus } from '../utils/errorHandlers'; +import { Logger } from '../utils/logger'; interface Domain { id: number; name: string; } +interface ScanCapabilities { + scannerAvailable: boolean; + scannerPath: string | null; + timeoutAvailable: boolean; + timeoutPath: string | null; +} +interface ScanResponse { + runId: string; + addr: string; + scanSeconds: number; + thread: number; + timeout: number; + startedAt: string; + endsAt: string; + finishedAt: string; + timedOut: boolean; + exitCode: number; + foundCount: number; + domains: string[]; + stderrTail: string; + stdoutTail: string; +} +interface ScanStatusResponse { + running: boolean; + runId: string | null; + addr: string | null; + scanSeconds: number | null; + thread: number | null; + timeout: number | null; + startedAt: string | null; + endsAt: string | null; + now: string; + remainingSeconds: number; + foundCount: number; + lastRunId: string | null; + lastFinishedAt: string | null; +} + +const SCAN_STORAGE_KEY = 'domains_scan_state_v1'; export default function DomainsPage() { const [domains, setDomains] = useState([]); @@ -15,21 +56,319 @@ export default function DomainsPage() { const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); + const [scanCapabilities, setScanCapabilities] = useState(null); + const [scanAddr, setScanAddr] = useState(''); + const [scanSeconds, setScanSeconds] = useState(30); + const [scanThread, setScanThread] = useState(2); + const [scanTimeout, setScanTimeout] = useState(5); + const [isScanning, setIsScanning] = useState(false); + const [scanError, setScanError] = useState(''); + const [scanResult, setScanResult] = useState(null); + const [scanCandidates, setScanCandidates] = useState([]); + const [scanPanelExpanded, setScanPanelExpanded] = useState(false); + const [scanStateHydrated, setScanStateHydrated] = useState(false); + const [scanStatus, setScanStatus] = useState(null); + const [activeScanRunId, setActiveScanRunId] = useState(null); + + // Snackbar state for notifications + const [snackbar, setSnackbar] = useState({ open: false, type: 'success' as 'success' | 'error', message: '' }); + + // Confirmation dialog state + const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} }); + + const clampInteger = (value: number, fallback: number, min: number, max: number) => { + const num = Number.isFinite(value) ? Math.floor(value) : fallback; + if (num < min) return min; + if (num > max) return max; + return num; + }; + + const isLoopbackHost = useCallback((value: string) => { + const host = value.trim().toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; + }, []); + + interface Settings { + xui_ip?: string; + xui_host?: string; + xui_url?: string; + } + + const collectAddrCandidatesFromSettings = useCallback((settings: Settings) => { + const candidates: string[] = []; + const xuiIp = String(settings?.xui_ip || '').trim(); + const xuiHost = String(settings?.xui_host || '').trim(); + const xuiUrl = String(settings?.xui_url || '').trim(); + + if (xuiIp) candidates.push(xuiIp); + if (xuiHost) candidates.push(xuiHost); + + if (xuiUrl) { + try { + const parsed = new URL(xuiUrl); + if (parsed.hostname) { + candidates.push(parsed.hostname.trim()); + } + } catch { + // Ignore malformed URL from settings and fall back to runtime hostname. + } + } + + return candidates.filter(Boolean); + }, []); + + const resolveSuggestedScanAddr = useCallback(async (opts?: { allowLoopbackFallback?: boolean }) => { + const allowLoopbackFallback = Boolean(opts?.allowLoopbackFallback); + let settingsCandidates: string[] = []; - const loadDomains = async () => { try { + const settingsRes = await api.get('/settings'); + Logger.debug('Domains page: Settings response', 'Domains', settingsRes.data); + + settingsCandidates = collectAddrCandidatesFromSettings(settingsRes.data); + Logger.debug('Domains page: Collected address candidates from settings', 'Domains', { + candidates: settingsCandidates, + xui_ip: settingsRes.data?.xui_ip, + xui_host: settingsRes.data?.xui_host, + xui_url: settingsRes.data?.xui_url + }); + + const publicFromSettings = settingsCandidates.find((c) => !isLoopbackHost(c)); + Logger.debug('Domains page: Looking for public address', 'Domains', { + publicFromSettings, + allCandidates: settingsCandidates + }); + + if (publicFromSettings) { + return publicFromSettings; + } + } catch (error) { + Logger.error('Failed to collect address candidates from settings', 'Domains', error); + } + + // Fallback: panel host where user opened 3dp (often the target VPS in real usage). + const runtimeHost = window.location.hostname; + Logger.debug('Domains page: Checking runtime host as fallback', 'Domains', { + runtimeHost, + isLoopback: isLoopbackHost(runtimeHost) + }); + + // Если настройки пустые и мы на localhost — предлагаем localhost с предупреждением + // Это позволяет пользователю начать работу и затем изменить на правильный IP + if (runtimeHost) { + if (!isLoopbackHost(runtimeHost)) { + Logger.debug('Domains page: Using runtime host as address', 'Domains', runtimeHost); + return runtimeHost; + } else if (allowLoopbackFallback) { + // Явно разрешили localhost fallback + Logger.debug('Domains page: Using localhost fallback (explicit)', 'Domains', runtimeHost); + return runtimeHost; + } else if (settingsCandidates.length === 0) { + // Настройки пустые — используем localhost как единственный вариант + Logger.warn('Domains page: No settings configured, using localhost as temporary placeholder', 'Domains'); + return runtimeHost; + } + } + + // Last resort: first from settings even if loopback + if (allowLoopbackFallback) { + const anyFromSettings = settingsCandidates[0]; + if (anyFromSettings) return anyFromSettings; + } + + Logger.warn('Domains page: No address found anywhere', 'Domains'); + return ''; + }, [collectAddrCandidatesFromSettings, isLoopbackHost]); + + const fetchScanStatus = useCallback(async () => { + const { data } = await api.get('/domains/scan/status'); + setScanStatus(data); + return data as ScanStatusResponse; + }, []); + + const fetchLastScanResult = useCallback(async (expectedRunId?: string | null) => { + const { data } = await api.get('/domains/scan/last-result'); + if (!data) return null; + if (expectedRunId && data.runId !== expectedRunId) return null; + + setScanResult(data); + setScanCandidates(data.domains || []); + return data as ScanResponse; + }, []); + + const loadDomains = useCallback(async () => { + try { + Logger.debug(`Loading page ${page + 1} (limit: ${rowsPerPage})`, 'Domains'); const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`); setDomains(data.data); setTotalCount(data.total); - } catch (e) { - console.error(e); + Logger.debug(`Loaded ${data.data.length} domains (total: ${data.total})`, 'Domains'); + } catch (error) { + Logger.error('Failed to load', 'Domains', error); } - }; + }, [page, rowsPerPage]); useEffect(() => { loadDomains(); - }, [page, rowsPerPage]); + }, [loadDomains]); + + useEffect(() => { + const loadScannerContext = async () => { + try { + const capRes = await api.get('/domains/scan/capabilities'); + setScanCapabilities(capRes.data); + if (capRes.data?.scannerAvailable) { + const status = await fetchScanStatus(); + if (status.running) { + setIsScanning(true); + setActiveScanRunId(status.runId); + setScanResult(null); + setScanCandidates([]); + setScanError(''); + } + } + } catch (error) { + Logger.error('Failed to load scanner context', 'Domains', error); + } + }; + + loadScannerContext(); + }, [fetchScanStatus]); + + useEffect(() => { + // Hydrate scanner UI state once so users do not lose pre-import review list after reload. + try { + const raw = localStorage.getItem(SCAN_STORAGE_KEY); + let restoredAddr: string | null = null; + + Logger.debug('Domains page: Starting hydrate', 'Domains', { + hasLocalStorage: !!raw, + localStorageValue: raw ? JSON.parse(raw).scanAddr : 'N/A' + }); + + if (raw) { + const parsed = JSON.parse(raw) as { + scanAddr?: string; + scanSeconds?: number; + scanThread?: number; + scanTimeout?: number; + scanResult?: ScanResponse | null; + scanCandidates?: string[]; + scanPanelExpanded?: boolean; + }; + + // Восстанавливаем только непустое значение + if (typeof parsed.scanAddr === 'string' && parsed.scanAddr.trim()) { + restoredAddr = parsed.scanAddr.trim(); + setScanAddr(restoredAddr); + Logger.debug(`Domains page: Restored scanAddr from localStorage: "${restoredAddr}"`, 'Domains'); + } else { + Logger.debug(`Domains page: scanAddr in localStorage is empty/whitespace, will fetch from settings`, 'Domains'); + } + if (typeof parsed.scanSeconds === 'number') setScanSeconds(parsed.scanSeconds); + if (typeof parsed.scanThread === 'number') setScanThread(parsed.scanThread); + if (typeof parsed.scanTimeout === 'number') setScanTimeout(parsed.scanTimeout); + if (parsed.scanResult) setScanResult(parsed.scanResult); + if (Array.isArray(parsed.scanCandidates)) setScanCandidates(parsed.scanCandidates); + if (typeof parsed.scanPanelExpanded === 'boolean') setScanPanelExpanded(parsed.scanPanelExpanded); + } else { + Logger.debug('Domains page: No localStorage data found', 'Domains'); + } + + // Если scanAddr не был восстановлен (пустой localStorage ИЛИ пустое значение), + // пытаемся получить домен из настроек + if (!restoredAddr) { + Logger.debug('Domains page: Fetching suggested address from settings...', 'Domains'); + // Пробуем сначала без localhost, если не найдём — разрешаем localhost fallback + resolveSuggestedScanAddr({ allowLoopbackFallback: false }).then((defaultAddr) => { + if (defaultAddr) { + setScanAddr(defaultAddr); + Logger.debug(`Domains page: Set scanAddr from settings: "${defaultAddr}"`, 'Domains'); + } else { + // Пытаемся с localhost fallback если совсем ничего не найдено + Logger.debug('Domains page: Trying with localhost fallback...', 'Domains'); + resolveSuggestedScanAddr({ allowLoopbackFallback: true }).then((fallbackAddr) => { + if (fallbackAddr) { + setScanAddr(fallbackAddr); + Logger.debug(`Domains page: Set scanAddr with localhost fallback: "${fallbackAddr}"`, 'Domains'); + } + }).catch((error) => { + Logger.error('Failed to resolve suggested scan address (fallback)', 'Domains', error); + }); + } + }).catch((error) => { + Logger.error('Failed to resolve suggested scan address', 'Domains', error); + }); + } else { + Logger.debug(`Domains page: Using restored scanAddr: "${restoredAddr}"`, 'Domains'); + } + } catch (error) { + Logger.error('Failed to hydrate scanner state from localStorage', 'Domains', error); + } finally { + setScanStateHydrated(true); + } + }, [resolveSuggestedScanAddr]); + + useEffect(() => { + if (!scanStateHydrated) return; + + try { + // Persist scanner input + results + accordion state for continuation after F5. + localStorage.setItem( + SCAN_STORAGE_KEY, + JSON.stringify({ + scanAddr, + scanSeconds, + scanThread, + scanTimeout, + scanResult, + scanCandidates, + scanPanelExpanded, + }), + ); + } catch (error) { + Logger.error('Failed to persist scanner state to localStorage', 'Domains', error); + } + }, [scanAddr, scanSeconds, scanThread, scanTimeout, scanResult, scanCandidates, scanPanelExpanded, scanStateHydrated]); + + useEffect(() => { + if (!isScanning) return; + + let cancelled = false; + + const tick = async () => { + try { + const status = await fetchScanStatus(); + if (cancelled) return; + + if (status.running) { + if (status.runId) { + setActiveScanRunId((prev) => prev ?? status.runId); + } + return; + } + + // Скан завершён — очищаем ошибку и загружаем результаты + setIsScanning(false); + setScanError(''); + const runIdToLoad = activeScanRunId || status.lastRunId; + await fetchLastScanResult(runIdToLoad); + setActiveScanRunId(null); + } catch (error) { + if (!cancelled) { + Logger.error('Failed to fetch scan status', 'Domains', error); + } + } + }; + + tick(); + const timer = window.setInterval(tick, 1000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [isScanning, activeScanRunId, fetchScanStatus, fetchLastScanResult]); const handleChangePage = (_event: unknown, newPage: number) => { setPage(newPage); @@ -42,23 +381,37 @@ export default function DomainsPage() { const handleAdd = async () => { if (!newDomain) return; + Logger.debug(`Adding domain: ${newDomain}`, 'Domains'); await api.post('/domains', { name: newDomain }); + Logger.debug(`Added domain: ${newDomain}`, 'Domains'); setNewDomain(''); loadDomains(); }; const handleDelete = async (id: number) => { + Logger.debug(`Deleting domain ID: ${id}`, 'Domains'); await api.delete(`/domains/${id}`); + Logger.debug(`Deleted domain ID: ${id}`, 'Domains'); loadDomains(); }; const handleDeleteAll = async () => { - if (confirm('ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?')) { - try { - await api.delete('/domains/all'); - loadDomains(); - } catch (_e) { alert('Ошибка удаления'); } - } + setConfirmDialog({ + open: true, + title: 'ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?', + onConfirm: async () => { + try { + Logger.debug('Deleting all domains', 'Domains'); + await api.delete('/domains/all'); + Logger.debug('All domains deleted', 'Domains'); + loadDomains(); + setSnackbar({ open: true, type: 'success', message: 'Все домены удалены' }); + } catch { + Logger.error('Delete all failed', 'Domains'); + setSnackbar({ open: true, type: 'error', message: 'Ошибка удаления' }); + } + } + }); }; const handleFileUpload = (event: React.ChangeEvent) => { @@ -74,10 +427,10 @@ export default function DomainsPage() { try { const { data } = await api.post('/domains/upload', { domains: lines }); - alert(`Успешно добавлено доменов: ${data.count}`); + setSnackbar({ open: true, type: 'success', message: `Успешно добавлено доменов: ${data.count}` }); loadDomains(); - } catch (_err) { - alert('Ошибка при загрузке списка'); + } catch { + setSnackbar({ open: true, type: 'error', message: 'Ошибка при загрузке списка' }); } finally { if (fileInputRef.current) fileInputRef.current.value = ''; } @@ -85,79 +438,434 @@ export default function DomainsPage() { reader.readAsText(file); }; + const handleStartScan = async () => { + if (!scanAddr.trim()) { + setSnackbar({ open: true, type: 'error', message: 'Укажите IP/домен для сканирования' }); + return; + } + + const effectiveScanSeconds = clampInteger(scanSeconds, 120, 10, 600); + const effectiveThread = clampInteger(scanThread, 2, 1, 20); + const effectiveTimeout = clampInteger(scanTimeout, 5, 1, 20); + let keepScanning = false; + + try { + Logger.debug(`Starting scan: addr=${scanAddr.trim()}, seconds=${effectiveScanSeconds}, threads=${effectiveThread}, timeout=${effectiveTimeout}`, 'Scanner'); + setIsScanning(true); + setScanError(''); + setScanResult(null); + setScanStatus(null); + setActiveScanRunId(null); + + const { data } = await api.post('/domains/scan/start', { + addr: scanAddr.trim(), + scanSeconds: effectiveScanSeconds, + thread: effectiveThread, + timeout: effectiveTimeout, + }); + + Logger.debug(`Scan started: runId=${data.runId}, found=${data.foundCount}`, 'Scanner'); + setScanResult(data); + setScanCandidates(data.domains || []); + setActiveScanRunId(data.runId || null); + await fetchScanStatus(); + } catch (e) { + const message = getApiErrorMessage(e, 'Ошибка запуска сканера'); + Logger.error(`Start error: ${message}`, 'Scanner'); + setScanError(message); + + const status = getApiErrorStatus(e); + if (status === 429) { + try { + const status = await fetchScanStatus(); + if (status.running) { + keepScanning = true; + setIsScanning(true); + setActiveScanRunId(status.runId); + setScanError('Скан уже выполняется. Подключились к текущему запуску.'); + Logger.debug('Connected to existing scan session', 'Scanner'); + } + } catch (statusErr) { + Logger.error('Failed to fetch scan status on 429', 'Scanner', statusErr); + } + } + } finally { + if (!keepScanning) { + setIsScanning(false); + setActiveScanRunId(null); + } + } + }; + + const handleImportScannedDomains = async () => { + const found = scanCandidates; + if (found.length === 0) return; + + try { + Logger.debug(`Importing ${found.length} scanned domains`, 'Domains'); + const { data } = await api.post('/domains/upload', { domains: found }); + Logger.debug(`Imported ${data.count} new domains`, 'Domains'); + setSnackbar({ open: true, type: 'success', message: `Скан завершен. Добавлено новых доменов: ${data.count}` }); + loadDomains(); + } catch { + Logger.error('Import failed', 'Domains'); + setSnackbar({ open: true, type: 'error', message: 'Ошибка импорта найденных доменов' }); + } + }; + + const handleRemoveScannedDomain = (domain: string) => { + setScanCandidates((prev) => prev.filter((d) => d !== domain)); + }; + + const handleClearScannedDomains = async () => { + setScanCandidates([]); + setScanResult(null); + setScanStatus(null); + setActiveScanRunId(null); + setScanAddr(''); + + const suggestedAddr = await resolveSuggestedScanAddr({ allowLoopbackFallback: true }); + setScanAddr(suggestedAddr); + }; + + const downloadDomainsAsTxt = (filename: string, domainNames: string[]) => { + const content = `${domainNames.join('\n')}\n`; + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }; + + const getExportTimestamp = () => new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + + const handleExportScannedDomains = () => { + if (scanCandidates.length === 0) return; + downloadDomainsAsTxt(`sni-scanned-${getExportTimestamp()}.txt`, scanCandidates); + }; + + const handleExportMainDomains = async () => { + if (domains.length === 0) return; + + try { + const { data } = await api.get('/domains/all'); + const names = (Array.isArray(data) ? data : []) + .map((d: Domain) => d.name) + .filter(Boolean); + + if (names.length === 0) return; + downloadDomainsAsTxt(`sni-whitelist-${getExportTimestamp()}.txt`, names); + } catch { + setSnackbar({ open: true, type: 'error', message: 'Ошибка экспорта списка' }); + } + }; + return ( Белый список доменов (SNI) - - setNewDomain(e.target.value)} - /> - {isMobile ? ( - <> - fileInputRef.current?.click()}> - - - ) : ( - <> - - - - )} - - - - {domains.length > 0 && ( - - - + + {scanCandidates.length > 0 && ( + + )} + + {isScanning && } + + {isScanning && ( + + {scanStatus?.running + ? scanStatus.remainingSeconds > 0 + ? `Сканирование выполняется. Осталось ${scanStatus.remainingSeconds} сек (по данным сервера). Найдено сейчас: ${scanStatus.foundCount}.` + : 'Сканирование завершается, ожидайте...' + : 'Сканирование запущено, получаем статус от сервера...'} + + )} + + {scanError && {scanError}} + + {scanResult && ( + + + + Найдено доменов: {scanResult.foundCount}. В предварительном списке: {scanCandidates.length}. + + + {scanResult.timedOut + ? `Скан остановлен по лимиту времени (${scanResult.scanSeconds} сек) - это нормальный режим поиска.` + : 'Скан завершен успешно.'} + {' '}(код: {scanResult.exitCode}) + + + Проверяйте домены кликом и удаляйте лишние перед импортом. + + + + {scanCandidates.map((d) => ( + handleRemoveScannedDomain(d)}> + + + } + > + + {d} + + } + /> + + ))} + {scanCandidates.length === 0 && ( + + + + )} + + + + )} + + + )} - 0 ? 0 : 3 }}> - - {domains.map((d) => ( - handleDelete(d.id)}> - }> - - - ))} - {domains.length === 0 && Нет доменов} - - `${from}–${to} из ${count !== -1 ? count : `более ${to}`}`} - /> + + + Управление белым списком (SNI) + + + setNewDomain(e.target.value)} + sx={{ flex: '1 1 280px' }} + /> + {isMobile ? ( + <> + fileInputRef.current?.click()}> + + + ) : ( + <> + + + + )} + + + + {domains.length > 0 && ( + + + + + )} + + + + {domains.map((d) => ( + handleDelete(d.id)}> + } + > + + {d.name} + + } + /> + + ))} + {domains.length === 0 && Нет доменов} + + `${from}–${to} из ${count !== -1 ? count : `более ${to}`}`} + /> + + + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })} + severity={snackbar.type} + sx={{ width: '100%' }} + > + {snackbar.message} + + + + setConfirmDialog({ ...confirmDialog, open: false })}> + Подтверждение действия + + {confirmDialog.title} + + + + + + ); -} \ No newline at end of file +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index a3109d0..195e88a 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -3,6 +3,9 @@ import { Box, Paper, TextField, Button, Typography, Alert, Chip } from '@mui/mat import { useNavigate } from 'react-router-dom'; import api from '../api'; import { useAuth } from '../auth/AuthContext'; +import { Logger } from '../utils/logger'; +import { getApiErrorMessage, getApiErrorStatus } from '../utils/errorHandlers'; +import { APP_VERSION } from '../utils/version'; export default function LoginPage() { const [creds, setCreds] = useState({ login: '', password: '' }); @@ -12,12 +15,38 @@ export default function LoginPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + Logger.debug(`Form submit → POST /api/auth/login`, 'Login', { + login: creds.login, + hasPassword: Boolean(creds.password), + }); try { const res = await api.post('/auth/login', creds); - login(res.data.access_token); + + const token = res.data.access_token; + Logger.debug(`Success → token received, calling login()`, 'Login'); + login(token); + + Logger.debug('Navigating to / after successful login', 'Login'); navigate('/'); - } catch (e) { - setError('Неверный логин или пароль'); + } catch (error: unknown) { + const status = getApiErrorStatus(error); + const message = getApiErrorMessage(error, 'Неверный логин или пароль'); + + // Rate limit error + if (status === 429) { + Logger.warn('Too many login attempts. Please try again later.', 'Login', { + status, + message, + }); + setError('Слишком много попыток входа. Попробуйте позже.'); + } else { + const logMethod = status === 401 ? Logger.warn : Logger.error; + logMethod('Login failed', 'Login', { + status: status ?? 'unknown', + message, + }); + setError('Неверный логин или пароль'); + } } }; @@ -35,7 +64,7 @@ export default function LoginPage() { animation: 'fadeIn 1.5s ease-out', boxShadow: '0 15px 25px rgba(0,0,0,0.5)' }}> - Вход в 3DP-MANAGER + Вход в 3DP-MANAGER {error && {error}} @@ -56,4 +85,4 @@ export default function LoginPage() { ); -} \ No newline at end of file +} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 63bcc9f..66b6d0e 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useState } from 'react'; -import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton, useTheme, useMediaQuery } from '@mui/material'; +import React, { useEffect, useState, useCallback } from 'react'; +import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton, useTheme, useMediaQuery, Dialog, DialogTitle, DialogContent, DialogActions, List, ListItem, FormControlLabel, Checkbox } from '@mui/material'; import api from '../api'; -import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Schedule, Update } from '@mui/icons-material'; +import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Refresh } from '@mui/icons-material'; +import { Logger } from '../utils/logger'; const ROTATION_PRESETS = [ { label: 'Сутки', value: 1440 }, @@ -9,6 +10,13 @@ const ROTATION_PRESETS = [ { label: 'Неделя', value: 10080 }, ]; +interface Subscription { + id: string; + name: string; + uuid: string; + isAutoRotationEnabled?: boolean; +} + export default function SettingsPage() { const [settings, setSettings] = useState({ xui_url: '', @@ -24,24 +32,60 @@ export default function SettingsPage() { password: '', }); + const [subs, setSubs] = useState([]); + const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' }); - const [intervalError, setIntervalError] = useState(''); const [loadingRotate, setLoadingRotate] = useState(false); + const [confirmDialog, setConfirmDialog] = useState({ + open: false, title: '', onConfirm: () => {}, + confirmText: 'Удалить', confirmColor: 'error' as 'error' | 'primary' + }); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); - useEffect(() => { - loadSettings(); + const loadSettings = useCallback(async () => { + try { + Logger.debug('Loading settings...', 'Settings'); + const { data } = await api.get('/settings'); + Logger.debug('Settings API response', 'Settings', data); + setSettings((prev) => ({ ...prev, ...data })); + Logger.debug('Settings after update', 'Settings', { + rotation_interval: data.rotation_interval, + prev_interval: prev => prev.rotation_interval + }); + + if (data.admin_login) { + setAdminProfile((prev) => ({ ...prev, login: data.admin_login })); + } + Logger.debug('Settings loaded successfully', 'Settings'); + } catch (error) { + Logger.error('Failed to load', 'Settings', error); + } + }, []); + + const loadSubscriptions = useCallback(async () => { + try { + Logger.debug('Loading subscriptions...', 'Settings'); + const { data } = await api.get('/subscriptions'); + setSubs(data); + Logger.debug(`Loaded ${data.length} subscriptions`, 'Settings'); + } catch (error) { + Logger.error('Failed to load subscriptions', 'Settings', error); + } }, []); useEffect(() => { + loadSettings(); + loadSubscriptions(); + }, [loadSettings, loadSubscriptions]); + + const getIntervalError = () => { const val = parseInt(settings.rotation_interval, 10); if (isNaN(val) || val < 10) { - setIntervalError('Минимальный интервал — 10 минут'); - } else { - setIntervalError(''); + return 'Минимальный интервал — 10 минут'; } - }, [settings.rotation_interval]); + return ''; + }; const cleanData = () => { const cleaned = { ...settings }; @@ -59,9 +103,10 @@ export default function SettingsPage() { }; const handleCheckConnection = async () => { - const data = cleanData(); // Сначала чистим + const data = cleanData(); try { + Logger.debug(`Checking connection to: ${data.xui_url}`, 'Settings'); setMsg({ open: true, type: 'success', text: 'Проверка...' }); const res = await api.post('/settings/check', { xui_url: data.xui_url, @@ -70,38 +115,46 @@ export default function SettingsPage() { }); if (res.data.success) { - setMsg({ open: true, type: 'success', text: 'Подключение успешно!' }); + Logger.debug('Connection check: SUCCESS', 'Settings'); + setMsg({ + open: true, + type: 'success', + text: 'Подключение успешно!' + }); } else { - setMsg({ open: true, type: 'error', text: 'Ошибка: Неверные данные или нет доступа' }); + Logger.warn('Connection check: FAILED', 'Settings', res.data); + setMsg({ + open: true, + type: 'error', + text: 'Ошибка: Неверные данные или нет доступа' + }); } - } catch (e) { + } catch (error) { + Logger.error('Connection check error', 'Settings', error); setMsg({ open: true, type: 'error', text: 'Ошибка сети при проверке' }); } }; - const loadSettings = async () => { - try { - const { data } = await api.get('/settings'); - setSettings((prev) => ({ ...prev, ...data })); - - if (data.admin_login) { - setAdminProfile((prev) => ({ ...prev, login: data.admin_login })); - } - } catch (e) { - console.error(e); - } - }; - - const handleSettingChange = (prop: string) => (event: React.ChangeEvent) => { - setSettings({ ...settings, [prop]: event.target.value }); - }; + const handleSettingChange = useCallback((prop: string) => (event: React.ChangeEvent) => { + setSettings(prev => ({ ...prev, [prop]: event.target.value })); + }, []); const handlePresetClick = (minutes: number) => { setSettings(prev => ({ ...prev, rotation_interval: minutes.toString() })); }; const handleSaveSettings = async () => { - if (intervalError) { + // Валидация полей подключения к 3x-ui + if (!settings.xui_url || !settings.xui_login || !settings.xui_password) { + setMsg({ + open: true, + text: 'Заполните все поля подключения к 3x-ui (URL, логин, пароль)', + type: 'error' + }); + return; + } + + if (getIntervalError()) { setMsg({ open: true, text: 'Исправьте ошибки перед сохранением', type: 'error' }); return; } @@ -109,47 +162,147 @@ export default function SettingsPage() { const data = cleanData(); try { + Logger.debug('Saving settings', 'Settings', { + xui_url: data.xui_url ? '***' : 'empty', + xui_login: data.xui_login, + rotation_interval: data.rotation_interval + }); await api.post('/settings', data); + Logger.debug('Settings saved successfully', 'Settings'); setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' }); - } catch (e) { + } catch (error) { + Logger.error('Save error', 'Settings', error); setMsg({ open: true, type: 'error', text: 'Ошибка сохранения' }); } }; - const handleAdminChange = (prop: string) => (event: React.ChangeEvent) => { - setAdminProfile({ ...adminProfile, [prop]: event.target.value }); + const handleSaveInterval = async () => { + if (getIntervalError()) { + setMsg({ open: true, text: 'Неверный интервал (минимум 10 минут)', type: 'error' }); + return; + } + + try { + Logger.debug('Saving rotation interval', 'Settings', { + rotation_interval: settings.rotation_interval + }); + await api.post('/settings', { + rotation_interval: settings.rotation_interval + }); + Logger.debug('Rotation interval saved successfully', 'Settings'); + setMsg({ open: true, type: 'success', text: 'Интервал генерации применён!' }); + } catch (error) { + Logger.error('Save interval error', 'Settings', error); + setMsg({ open: true, type: 'error', text: 'Ошибка сохранения интервала' }); + } }; + const handleAdminChange = useCallback((prop: string) => (event: React.ChangeEvent) => { + setAdminProfile(prev => ({ ...prev, [prop]: event.target.value })); + }, []); + const handleSaveAdmin = async () => { try { + Logger.debug('Updating admin profile', 'Settings', { login: adminProfile.login }); await api.post('/auth/update-profile', adminProfile); + Logger.debug('Admin profile updated', 'Settings'); setMsg({ open: true, type: 'success', text: 'Профиль администратора обновлен!' }); setAdminProfile(prev => ({ ...prev, password: '' })); - } catch (e) { + } catch (error) { + Logger.error('Update admin profile error', 'Settings', error); setMsg({ open: true, type: 'error', text: 'Ошибка обновления профиля' }); } }; const handleForceRotate = async () => { - if (confirm('ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?')) { - try { - setLoadingRotate(true); - const res = await api.post('/rotation/rotate-all'); + setConfirmDialog({ + open: true, + title: 'ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?', + confirmText: 'Сгенерировать', + confirmColor: 'primary', + onConfirm: async () => { + try { + Logger.debug('Starting forced rotation', 'Rotation'); + setLoadingRotate(true); + const res = await api.post('/rotation/rotate-all'); - setLoadingRotate(false); - if (res.data && res.data.success) { - setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация успешно выполнена!' }); - } else { - setMsg({ - open: true, - type: 'error', - text: res.data?.message || 'Ошибка выполнения ротации' - }); + setLoadingRotate(false); + if (res.data && res.data.success) { + Logger.debug('Rotation completed successfully', 'Rotation'); + setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация успешно выполнена!' }); + } else { + Logger.warn('Rotation completed with issues', 'Rotation', res.data?.message); + setMsg({ + open: true, + type: 'error', + text: res.data?.message || 'Ошибка выполнения ротации' + }); + } + } catch (error) { + setLoadingRotate(false); + Logger.error('Rotation error', 'Rotation', error); + setMsg({ open: true, type: 'error', text: 'Ошибка сети или сервера' }); } - } catch (e) { - setLoadingRotate(false); - setMsg({ open: true, type: 'error', text: 'Ошибка сети или сервера' }); } + }); + }; + + const handleToggleAutoRotation = async (subscriptionId: string, enabled: boolean) => { + try { + await api.put('/subscriptions/bulk-auto-rotation', { + subscriptionIds: [subscriptionId], + enabled + }); + setSubs(prev => prev.map(s => + s.id === subscriptionId ? { ...s, isAutoRotationEnabled: enabled } : s + )); + setMsg({ + open: true, + type: 'success', + text: enabled ? 'Авторотация включена' : 'Авторотация выключена' + }); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка обновления'; + Logger.error(`Toggle auto-rotation error: ${message}`, 'Settings'); + setMsg({ open: true, type: 'error', text: message }); + loadSubscriptions(); + } + }; + + const handleManualRotate = async (sub: Subscription) => { + setConfirmDialog({ + open: true, + title: `Обновить подписку "${sub.name}" сейчас?`, + confirmText: 'Обновить', + confirmColor: 'primary', + onConfirm: async () => { + try { + Logger.debug(`Starting manual rotation for subscription: ${sub.id}`, 'Settings'); + const res = await api.post(`/rotation/rotate-one/${sub.id}`); + Logger.debug('Manual rotation completed', 'Settings'); + setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация выполнена' }); + loadSubscriptions(); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка ротации'; + Logger.error(`Manual rotation error: ${message}`, 'Settings'); + setMsg({ open: true, type: 'error', text: message }); + } + } + }); + }; + + const handleBulkUpdate = async (enabled: boolean) => { + try { + const { data } = await api.put('/subscriptions/bulk-auto-rotation', { + subscriptionIds: subs.map(s => s.id), + enabled + }); + setMsg({ open: true, type: 'success', text: data.message || 'Настройки обновлены' }); + loadSubscriptions(); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка обновления'; + Logger.error(`Bulk update error: ${message}`, 'Settings'); + setMsg({ open: true, type: 'error', text: message }); } }; @@ -157,13 +310,15 @@ export default function SettingsPage() { const newStatus = settings.rotation_status === 'active' ? 'stopped' : 'active'; const updatedSettings = { ...settings, rotation_status: newStatus }; + Logger.debug(`Toggling rotation status: ${settings.rotation_status} → ${newStatus}`, 'Settings'); setSettings(updatedSettings); try { await api.post('/settings', updatedSettings); - - } catch (e) { - setSettings((prev: any) => ({ ...prev, rotation_status: settings.rotation_status })); + Logger.debug('Rotation status updated', 'Settings'); + } catch (error) { + Logger.error('Toggle pause error', 'Settings', error); + setSettings((prev) => ({ ...prev, rotation_status: prev.rotation_status })); setMsg({ open: true, type: 'error', text: 'Не удалось изменить статус' }); } }; @@ -316,7 +471,7 @@ export default function SettingsPage() { /> ))} - + + + + + Управление авторотацией подписок + + + Выберите подписки для автоматической ротации: + + + {subs.length === 0 ? ( + + Нет активных подписок + + ) : ( + + {subs.map(sub => ( + + handleToggleAutoRotation(sub.id, e.target.checked)} + color="primary" + /> + } + label={ + + {sub.name} + + {sub.uuid.substring(0, 8)}... + + + } + sx={{ flexGrow: 1 }} + /> + + handleManualRotate(sub)} + color="primary" + > + + + + + ))} + + )} + + {subs.length > 0 && ( + + + + + )} @@ -357,6 +588,28 @@ export default function SettingsPage() { setMsg({ ...msg, open: false })}> {msg.text} + + setConfirmDialog({ ...confirmDialog, open: false })}> + Подтверждение действия + + {confirmDialog.title} + + + + + + ); } \ No newline at end of file diff --git a/client/src/pages/SubscriptionsPage.tsx b/client/src/pages/SubscriptionsPage.tsx index bfdb1bf..c47dc5d 100644 --- a/client/src/pages/SubscriptionsPage.tsx +++ b/client/src/pages/SubscriptionsPage.tsx @@ -1,24 +1,28 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Box, Button, Typography, Paper, Table, TableBody, TableCell, TableHead, TableRow, IconButton, Dialog, DialogTitle, DialogContent, TextField, DialogActions, FormControl, Select, - InputAdornment, InputLabel, MenuItem, + InputAdornment, InputLabel, MenuItem, Snackbar, Alert, useTheme, useMediaQuery, Menu, ListItemIcon, - ListItemText + ListItemText, + Checkbox } from '@mui/material'; -import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove } from '@mui/icons-material'; +import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove, Refresh } from '@mui/icons-material'; import api from '../api'; +import { copyToClipboard } from '../utils/copyToClipboard'; +import { Logger } from '../utils/logger'; interface Subscription { id: string; name: string; uuid: string; - inbounds: any[]; - inboundsConfig?: any[]; + inbounds: unknown[]; + inboundsConfig?: unknown[]; + isAutoRotationEnabled?: boolean; } interface Tunnel { @@ -61,7 +65,7 @@ const patchLink = function (link: string, newHost: string): string { const newJsonStr = JSON.stringify(config); const newBase64 = Buffer.from(newJsonStr).toString('base64'); return `vmess://${newBase64}`; - } catch (e) { + } catch { return link; } } else if (link.startsWith('vless://') || link.startsWith('trojan://')) { @@ -77,6 +81,17 @@ const patchLink = function (link: string, newHost: string): string { const generateId = () => Math.random().toString(36).substring(7); +const getSubscriptionUrl = (uuid: string, tunnelId: string | number) => { + // Используем относительный путь - Nginx проксирует /bus/ на бэкенд + const tunnelPart = tunnelId !== 'main' ? `/${tunnelId}` : ''; + const path = `/bus/${uuid}${tunnelPart}`; + // Для копирования нужен полный URL с origin + if (typeof window !== 'undefined') { + return `${window.location.origin}${path}`; + } + return path; +}; + export default function SubscriptionsPage() { const [subs, setSubs] = useState([]); const [tunnels, setTunnels] = useState([]); @@ -97,21 +112,39 @@ export default function SubscriptionsPage() { const [linksOpen, setLinksOpen] = useState(false); const [currentLinks, setCurrentLinks] = useState([]); + // Snackbar state for notifications + const [snackbar, setSnackbar] = useState({ open: false, type: 'success' as 'success' | 'error', message: '' }); + + // Confirmation dialog state + const [confirmDialog, setConfirmDialog] = useState({ + open: false, title: '', onConfirm: () => {}, + confirmText: 'Удалить', confirmColor: 'error' as 'error' | 'primary' + }); + const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); - useEffect(() => { loadSubs(); }, []); + const loadSubs = useCallback(async () => { + try { + Logger.debug('Loading subscriptions...', 'Subs'); + const { data } = await api.get('/subscriptions'); + setSubs(data); + Logger.debug(`Loaded ${data.length} subscriptions`, 'Subs'); - const loadSubs = async () => { - const { data } = await api.get('/subscriptions'); - setSubs(data); + const tunnelsRes = await api.get('/tunnels'); + setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled)); + Logger.debug(`Loaded ${tunnelsRes.data.filter((el: Tunnel) => el.isInstalled).length} active tunnels`, 'Subs'); - const tunnelsRes = await api.get('/tunnels'); - setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled)); + const allDomains = await api.get('/domains/all'); + setDomains(allDomains.data); + Logger.debug(`Loaded ${allDomains.data.length} domains`, 'Subs'); + } catch (error) { + Logger.error('Failed to load', 'Subs', error); + throw error; + } + }, []); - const allDomains = await api.get('/domains/all'); - setDomains(allDomains.data); - }; + useEffect(() => { loadSubs(); }, [loadSubs]); const handleActionMenuClick = (event: React.MouseEvent, sub: Subscription) => { setMenuAnchorEl(event.currentTarget); @@ -201,11 +234,11 @@ export default function SubscriptionsPage() { const handleSave = async () => { if (Object.keys(portErrors).length > 0) { - alert('Пожалуйста, исправьте ошибки с портами'); + setSnackbar({ open: true, type: 'error', message: 'Пожалуйста, исправьте ошибки с портами' }); return; } if (!name.trim()) { - alert('Введите имя подписки'); + setSnackbar({ open: true, type: 'error', message: 'Введите имя подписки' }); return; } @@ -224,32 +257,92 @@ export default function SubscriptionsPage() { }; try { + Logger.debug(`${editingId ? 'Updating' : 'Creating'} subscription`, 'Subs', payload); if (editingId) { await api.put(`/subscriptions/${editingId}`, payload); + Logger.debug(`Updated subscription ${editingId}`, 'Subs'); } else { await api.post('/subscriptions', payload); + Logger.debug('Created subscription', 'Subs'); } setOpen(false); loadSubs(); - } catch (error: any) { - alert(error.response?.data?.message || 'Произошла ошибка при сохранении'); + setSnackbar({ open: true, type: 'success', message: editingId ? 'Подписка обновлена' : 'Подписка создана' }); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Произошла ошибка при сохранении'; + Logger.error(`Save error: ${message}`, 'Subs'); + setSnackbar({ open: true, type: 'error', message }); } }; const handleDelete = async (id: string) => { - if (confirm('Удалить подписку и все соединения?')) { - await api.delete(`/subscriptions/${id}`); + setConfirmDialog({ + open: true, + title: 'Удалить подписку и все соединения?', + confirmText: 'Удалить', + confirmColor: 'error', + onConfirm: async () => { + Logger.debug(`Deleting subscription: ${id}`, 'Subs'); + await api.delete(`/subscriptions/${id}`); + Logger.debug(`Deleted subscription ${id}`, 'Subs'); + loadSubs(); + setSnackbar({ open: true, type: 'success', message: 'Подписка удалена' }); + } + }); + }; + + const handleToggleAutoRotation = async (subscriptionId: string, enabled: boolean) => { + try { + await api.put('/subscriptions/bulk-auto-rotation', { + subscriptionIds: [subscriptionId], + enabled + }); + setSubs(prev => prev.map(s => + s.id === subscriptionId ? { ...s, isAutoRotationEnabled: enabled } : s + )); + setSnackbar({ + open: true, + type: 'success', + message: enabled ? 'Авторотация включена' : 'Авторотация выключена' + }); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка обновления'; + Logger.error(`Toggle auto-rotation error: ${message}`, 'Subs'); + setSnackbar({ open: true, type: 'error', message }); loadSubs(); } }; + const handleManualRotate = async (sub: Subscription) => { + setConfirmDialog({ + open: true, + title: `Обновить подписку "${sub.name}" сейчас?`, + confirmText: 'Обновить', + confirmColor: 'primary', + onConfirm: async () => { + try { + Logger.debug(`Starting manual rotation for subscription: ${sub.id}`, 'Subs'); + const res = await api.post(`/rotation/rotate-one/${sub.id}`); + Logger.debug('Manual rotation completed', 'Subs'); + setSnackbar({ open: true, type: 'success', message: res.data.message || 'Ротация выполнена' }); + loadSubs(); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка ротации'; + Logger.error(`Manual rotation error: ${message}`, 'Subs'); + setSnackbar({ open: true, type: 'error', message }); + } + } + }); + }; + const showLinks = (sub: Subscription) => { - let links = []; + let links: string[] = []; if (selectedServer === 'main') { - links = sub.inbounds?.map(i => i.link).filter(Boolean) || []; + links = sub.inbounds?.map(i => (i as { link?: string }).link).filter(Boolean) || []; } else { - const host = tunnels[+selectedServer - 1].domain.length > 0 ? tunnels[+selectedServer - 1].domain : tunnels[+selectedServer - 1].ip; - links = sub.inbounds?.map(i => patchLink(i.link, host)).filter(Boolean) || []; + const tunnelIndex = +selectedServer - 1; + const host = tunnels[tunnelIndex]?.domain?.length > 0 ? tunnels[tunnelIndex].domain : tunnels[tunnelIndex].ip; + links = sub.inbounds?.map(i => patchLink((i as { link?: string }).link || '', host)).filter(Boolean) || []; } if (links.length === 0) { setCurrentLinks(['Нет активных ссылок (ждите ротации)']); @@ -259,6 +352,11 @@ export default function SubscriptionsPage() { setLinksOpen(true); }; + const handleCopyLink = async (uuid: string, tunnelId: string | number) => { + await copyToClipboard(getSubscriptionUrl(uuid, tunnelId)); + setSnackbar({ open: true, type: 'success', message: 'Ссылка на подписку скопирована' }); + }; + return ( @@ -297,6 +395,7 @@ export default function SubscriptionsPage() { Имя UUID Инбаунды + Авторотация Действия @@ -306,19 +405,26 @@ export default function SubscriptionsPage() { {sub.name} {sub.uuid} {sub.inbounds?.length || 0} + + handleToggleAutoRotation(sub.id, e.target.checked)} + color="primary" + /> + {!isMobile && ( <> navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`)} + onClick={() => handleCopyLink(sub.uuid, selectedServer)} title="Копировать ссылку" > window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')} + onClick={() => window.open(getSubscriptionUrl(sub.uuid, selectedServer), '_blank')} title="Открыть подписку" > @@ -346,13 +452,13 @@ export default function SubscriptionsPage() { transformOrigin={{ vertical: 'top', horizontal: 'right' }} > {isMobile && activeSub && ( - navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`)}> + handleCopyLink(activeSub.uuid, selectedServer)}> Копировать ссылку )} {isMobile && activeSub && ( - window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`, '_blank')}> + window.open(getSubscriptionUrl(activeSub.uuid, selectedServer), '_blank')}> Открыть подписку @@ -364,6 +470,12 @@ export default function SubscriptionsPage() { Показать конфиги )} + {activeSub && ( + handleManualRotate(activeSub)}> + + Обновить сейчас + + )} {activeSub && ( handleOpenEdit(activeSub)}> @@ -498,10 +610,47 @@ export default function SubscriptionsPage() { /> - + + + {/* Confirmation Dialog */} + setConfirmDialog({ ...confirmDialog, open: false })}> + Подтверждение + + {confirmDialog.title} + + + + + + + + {/* Snackbar notifications */} + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })} + severity={snackbar.type} + sx={{ width: '100%' }} + > + {snackbar.message} + + ); } \ No newline at end of file diff --git a/client/src/pages/TunnelsPage.tsx b/client/src/pages/TunnelsPage.tsx index 4aa2e47..d033c3a 100644 --- a/client/src/pages/TunnelsPage.tsx +++ b/client/src/pages/TunnelsPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { Box, Button, Typography, Paper, Table, TableBody, TableCell, TableHead, TableRow, IconButton, Dialog, DialogTitle, @@ -8,10 +8,14 @@ import { FormControl, RadioGroup, FormControlLabel, - Radio + Radio, + Snackbar, + Alert } from '@mui/material'; import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material'; import api from '../api'; +import { getApiErrorMessage } from '../utils/errorHandlers'; +import { Logger } from '../utils/logger'; interface Tunnel { id: number; @@ -34,54 +38,133 @@ export default function TunnelsPage() { name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: '' }); - useEffect(() => { loadTunnels(); }, []); + // Snackbar state for notifications + const [snackbar, setSnackbar] = useState({ open: false, type: 'success' as 'success' | 'error', message: '' }); - const loadTunnels = async () => { - try { - const { data } = await api.get('/tunnels'); - setTunnels(data); - } catch (e) { console.error(e); } + // Confirmation dialog state + const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} }); + + // Form validation errors + const [formErrors, setFormErrors] = useState>({}); + + const validateForm = () => { + const errors: Record = {}; + + if (!form.name.trim()) { + errors.name = 'Введите название сервера'; + } + + if (!form.ip.trim()) { + errors.ip = 'Введите IP адрес'; + } else { + // IPv4 validation + const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + // IPv6 basic validation + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:){1,7}:$|^([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}$|^([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}$|^([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}$|^([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})$|^:((:[0-9a-fA-F]{1,4}){1,7}|:)$/; + + if (!ipv4Regex.test(form.ip) && !ipv6Regex.test(form.ip)) { + errors.ip = 'Неверный формат IP адреса'; + } + } + + if (!form.sshPort || form.sshPort < 1 || form.sshPort > 65535) { + errors.sshPort = 'Порт должен быть от 1 до 65535'; + } + + if (!form.username.trim()) { + errors.username = 'Введите SSH пользователя'; + } + + if (authMethod === 'password' && !form.password) { + errors.password = 'Введите SSH пароль'; + } + + if (authMethod === 'key' && !form.privateKey.trim()) { + errors.privateKey = 'Введите SSH ключ'; + } else if (authMethod === 'key' && !form.privateKey.includes('-----BEGIN')) { + errors.privateKey = 'Неверный формат SSH ключа'; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; }; + const loadTunnels = useCallback(async () => { + try { + Logger.debug('Loading tunnels...', 'Tunnels'); + const { data } = await api.get('/tunnels'); + setTunnels(data); + Logger.debug(`Loaded ${data.length} tunnels`, 'Tunnels'); + } catch (error) { + Logger.error('Failed to load', 'Tunnels', error); + } + }, []); + + useEffect(() => { loadTunnels(); }, [loadTunnels]); + const handleCreate = async () => { + if (!validateForm()) { + setSnackbar({ open: true, type: 'error', message: 'Исправьте ошибки в форме' }); + return; + } + const payload = { ...form, password: authMethod === 'password' ? form.password : null, privateKey: authMethod === 'key' ? form.privateKey : null, }; + Logger.debug(`Creating tunnel`, 'Tunnels', { name: form.name, ip: form.ip }); await api.post('/tunnels', payload); + Logger.debug('Tunnel created successfully', 'Tunnels'); setOpen(false); setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: '' }); setAuthMethod('password'); + setFormErrors({}); loadTunnels(); + setSnackbar({ open: true, type: 'success', message: 'Сервер добавлен' }); }; const handleDelete = async (id: number) => { - if (confirm('Удалить сервер из списка?')) { - await api.delete(`/tunnels/${id}`); - loadTunnels(); - } + setConfirmDialog({ + open: true, + title: 'Удалить сервер из списка?', + onConfirm: async () => { + Logger.debug(`Deleting tunnel ID: ${id}`, 'Tunnels'); + await api.delete(`/tunnels/${id}`); + Logger.debug(`Deleted tunnel ID: ${id}`, 'Tunnels'); + loadTunnels(); + setSnackbar({ open: true, type: 'success', message: 'Сервер удалён' }); + } + }); }; const handleInstall = async (id: number) => { - if (!confirm('Начать установку перенаправления на этот сервер?')) return; - - setLoadingId(id); - try { - await api.post(`/tunnels/${id}/install`); - alert('Скрипт успешно установлен! Трафик перенаправляется.'); - loadTunnels(); - } catch (e: any) { - alert('Ошибка: ' + (e.response?.data?.message || e.message)); - } finally { - setLoadingId(null); - } + setConfirmDialog({ + open: true, + title: 'Начать установку перенаправления на этот сервер?', + onConfirm: async () => { + Logger.debug(`Installing forwarding on tunnel ID: ${id}`, 'Tunnels'); + setLoadingId(id); + try { + await api.post(`/tunnels/${id}/install`); + Logger.debug('Forwarding installed successfully', 'Tunnels'); + setSnackbar({ open: true, type: 'success', message: 'Скрипт успешно установлен! Трафик перенаправляется.' }); + loadTunnels(); + } catch (e) { + const message = getApiErrorMessage(e, 'Неизвестная ошибка'); + Logger.error(`Install error on ID ${id}: ${message}`, 'Tunnels'); + setSnackbar({ open: true, type: 'error', message: 'Ошибка: ' + message }); + } finally { + setLoadingId(null); + } + } + }); }; - const handleChange = (prop: string) => (e: React.ChangeEvent) => { - setForm({ ...form, [prop]: e.target.value }); - }; + const handleChange = useCallback((prop: string) => (e: React.ChangeEvent) => { + setForm(prev => ({ ...prev, [prop]: e.target.value })); + }, []); return ( @@ -155,11 +238,44 @@ export default function TunnelsPage() { setOpen(false)}> Новый редирект сервер - - + + - - + + setAuthMethod(e.target.value as 'password' | 'key')}> @@ -169,18 +285,29 @@ export default function TunnelsPage() { {authMethod === 'password' ? ( - + ) : ( - )} @@ -189,6 +316,43 @@ export default function TunnelsPage() { + + {/* Confirmation Dialog */} + setConfirmDialog({ ...confirmDialog, open: false })}> + Подтверждение + + {confirmDialog.title} + + + + + + + + {/* Snackbar notifications */} + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })} + severity={snackbar.type} + sx={{ width: '100%' }} + > + {snackbar.message} + + ); } \ No newline at end of file diff --git a/client/src/types/auth.ts b/client/src/types/auth.ts new file mode 100644 index 0000000..5adb8a9 --- /dev/null +++ b/client/src/types/auth.ts @@ -0,0 +1,6 @@ +export type AuthContextType = { + token: string | null; + isAuthenticated: boolean; + login: (token: string) => void; + logout: () => void; +}; diff --git a/client/src/types/theme.ts b/client/src/types/theme.ts new file mode 100644 index 0000000..97b8eaf --- /dev/null +++ b/client/src/types/theme.ts @@ -0,0 +1,6 @@ +export type ColorMode = 'light' | 'dark' | 'system'; + +export type ThemeContextType = { + mode: ColorMode; + toggleColorMode: () => void; +}; diff --git a/client/src/utils/copyToClipboard.ts b/client/src/utils/copyToClipboard.ts new file mode 100644 index 0000000..0e01159 --- /dev/null +++ b/client/src/utils/copyToClipboard.ts @@ -0,0 +1,25 @@ +/** + * Универсальная функция копирования текста в буфер обмена. + * Работает и в secure (HTTPS/localhost), и в insecure (HTTP по IP) контекстах. + * navigator.clipboard недоступен при не-HTTPS/не-localhost соединениях. + */ +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + // Fallback для HTTP (не-localhost) — использует execCommand + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + document.execCommand('copy'); + } finally { + document.body.removeChild(textarea); + } + } +} diff --git a/client/src/utils/errorHandlers.ts b/client/src/utils/errorHandlers.ts new file mode 100644 index 0000000..af146bd --- /dev/null +++ b/client/src/utils/errorHandlers.ts @@ -0,0 +1,46 @@ +/** + * Type guard to check if a value is an API error response + */ +export function isApiError(error: unknown): error is { response?: { status?: number; data?: { message?: string | string[] } } } { + return ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof (error as { response?: unknown }).response === 'object' && + (error as { response?: unknown }).response !== null + ); +} + +/** + * Extract error message from API error response + */ +export function getApiErrorMessage(error: unknown, defaultMessage: string = 'Произошла ошибка'): string { + if (isApiError(error)) { + const data = error.response?.data; + const message = data?.message; + + if (Array.isArray(message)) { + return message.join('; '); + } + + if (typeof message === 'string') { + return message; + } + } + + if (error instanceof Error) { + return error.message; + } + + return defaultMessage; +} + +/** + * Get HTTP status code from error response + */ +export function getApiErrorStatus(error: unknown): number | undefined { + if (isApiError(error)) { + return error.response?.status; + } + return undefined; +} diff --git a/client/src/utils/logger.ts b/client/src/utils/logger.ts new file mode 100644 index 0000000..facab74 --- /dev/null +++ b/client/src/utils/logger.ts @@ -0,0 +1,57 @@ +type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'; + +const LOG_LEVELS: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, + verbose: 4, +}; + +const getLogLevel = (): LogLevel => { + return (import.meta.env.VITE_LOG_LEVEL as LogLevel) || 'info'; +}; + +const shouldLog = (level: LogLevel): boolean => { + const currentLevel = getLogLevel(); + return LOG_LEVELS[level] <= LOG_LEVELS[currentLevel]; +}; + +const formatMessage = (module: string, message: string, data?: unknown): string => { + if (data !== undefined) { + return `[${module}] ${message} ${JSON.stringify(data)}`; + } + return `[${module}] ${message}`; +}; + +export const Logger = { + error: (message: string, module: string = 'App', data?: unknown) => { + if (shouldLog('error')) { + console.error(formatMessage(module, message), data || ''); + } + }, + + warn: (message: string, module: string = 'App', data?: unknown) => { + if (shouldLog('warn')) { + console.warn(formatMessage(module, message), data || ''); + } + }, + + info: (message: string, module: string = 'App', data?: unknown) => { + if (shouldLog('info')) { + console.info(formatMessage(module, message), data || ''); + } + }, + + debug: (message: string, module: string = 'App', data?: unknown) => { + if (shouldLog('debug')) { + console.log(formatMessage(module, message), data || ''); + } + }, + + verbose: (message: string, module: string = 'App', data?: unknown) => { + if (shouldLog('verbose')) { + console.log(formatMessage(module, message), data || ''); + } + }, +}; diff --git a/client/src/utils/useSecureConnection.ts b/client/src/utils/useSecureConnection.ts new file mode 100644 index 0000000..01c3b82 --- /dev/null +++ b/client/src/utils/useSecureConnection.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; + +/** + * Хук для определения безопасного соединения (HTTPS) + * @returns {isSecure: boolean} - true если соединение по HTTPS + */ +export function useSecureConnection() { + const isSecure = useMemo(() => { + // Проверка в браузере + if (typeof window !== 'undefined' && window.location) { + return window.location.protocol === 'https:'; + } + // SSR fallback - считаем небезопасным + return false; + }, []); + + return { isSecure }; +} diff --git a/client/src/utils/version.ts b/client/src/utils/version.ts new file mode 100644 index 0000000..ecc97f5 --- /dev/null +++ b/client/src/utils/version.ts @@ -0,0 +1 @@ +export const APP_VERSION = '2.1.0'; diff --git a/client/test/App.spec.tsx b/client/test/App.spec.tsx new file mode 100644 index 0000000..9949cf4 --- /dev/null +++ b/client/test/App.spec.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import App from '../src/App' + +// Мок для страниц и компонентов +vi.mock('../src/pages/LoginPage', () => ({ + default: () =>
LoginPage
, +})) + +vi.mock('../src/pages/SubscriptionsPage', () => ({ + default: () =>
SubscriptionsPage
, +})) + +vi.mock('../src/pages/SettingsPage', () => ({ + default: () =>
SettingsPage
, +})) + +vi.mock('../src/pages/DomainsPage', () => ({ + default: () =>
DomainsPage
, +})) + +vi.mock('../src/pages/TunnelsPage', () => ({ + default: () =>
TunnelsPage
, +})) + +vi.mock('../src/pages/NotFoundPage', () => ({ + default: () =>
NotFoundPage
, +})) + +vi.mock('../src/components/Layout', () => ({ + default: () =>
Layout
, +})) + +// Мок для AuthContext +vi.mock('../src/auth/AuthContext', () => ({ + AuthProvider: ({ children }: { children: React.ReactNode }) => children, + useAuth: () => ({ + isAuthenticated: false, + login: vi.fn(), + logout: vi.fn(), + }), +})) + +// Мок для ThemeContext +vi.mock('../src/ThemeContext', () => ({ + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, + useThemeContext: () => ({ + mode: 'light' as const, + toggleColorMode: vi.fn(), + }), +})) + +// Мок для AxiosInterceptor +vi.mock('../src/auth/AxiosInterceptor', () => ({ + AxiosInterceptor: () => null, +})) + +// Мок для RequireAuth - просто рендерит children +vi.mock('../src/auth/RequireAuth', () => ({ + default: ({ children }: { children: React.ReactNode }) => children, +})) + +// Мок для PublicRoute - просто рендерит children +vi.mock('../src/auth/PublicRoute', () => ({ + default: ({ children }: { children: React.ReactNode }) => children, +})) + +describe('App', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('должен рендериться без ошибок', () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('должен содержать Layout компонент', () => { + render() + expect(screen.getByTestId('layout')).toBeInTheDocument() + }) +}) diff --git a/client/test/ThemeContext.spec.tsx b/client/test/ThemeContext.spec.tsx new file mode 100644 index 0000000..f1953a4 --- /dev/null +++ b/client/test/ThemeContext.spec.tsx @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { ThemeProvider, useThemeContext } from '@/ThemeContext' +import { ReactNode } from 'react' + +// Мокаем localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value + }), + removeItem: vi.fn((key: string) => { + delete store[key] + }), + clear: vi.fn(() => { + store = {} + }), + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}) + +// Мокаем useMediaQuery +const useMediaQueryMock = vi.fn() +vi.mock('@mui/material', async () => { + const actual = await vi.importActual('@mui/material') + return { + ...(actual as object), + useMediaQuery: () => useMediaQueryMock(), + } +}) + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +) + +describe('ThemeContext', () => { + beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + useMediaQueryMock.mockReturnValue(false) // light mode by default + }) + + describe('useThemeContext', () => { + it('должен предоставлять context', () => { + const { result } = renderHook(() => useThemeContext(), { wrapper }) + expect(result.current).toBeDefined() + }) + + it('должен предоставлять mode и toggleColorMode', () => { + const { result } = renderHook(() => useThemeContext(), { wrapper }) + expect(result.current.mode).toBeDefined() + expect(result.current.toggleColorMode).toBeDefined() + expect(typeof result.current.toggleColorMode).toBe('function') + }) + }) + + describe('initial state', () => { + it('должен инициализироваться с mode из localStorage', () => { + localStorageMock.getItem.mockReturnValue('dark') + + const { result } = renderHook(() => useThemeContext(), { wrapper }) + + expect(result.current.mode).toBe('dark') + }) + + it('должен инициализироваться с "system" если mode отсутствует в localStorage', () => { + localStorageMock.getItem.mockReturnValue(null) + + const { result } = renderHook(() => useThemeContext(), { wrapper }) + + expect(result.current.mode).toBe('system') + }) + }) + + describe('toggleColorMode', () => { + it('должен переключать light → dark → system → light', () => { + localStorageMock.getItem.mockReturnValue('light') + + const { result } = renderHook(() => useThemeContext(), { wrapper }) + + expect(result.current.mode).toBe('light') + + act(() => { + result.current.toggleColorMode() + }) + expect(result.current.mode).toBe('dark') + + act(() => { + result.current.toggleColorMode() + }) + expect(result.current.mode).toBe('system') + + act(() => { + result.current.toggleColorMode() + }) + expect(result.current.mode).toBe('light') + }) + + it('должен сохранять mode в localStorage при переключении', () => { + localStorageMock.getItem.mockReturnValue('light') + + const { result } = renderHook(() => useThemeContext(), { wrapper }) + + act(() => { + result.current.toggleColorMode() + }) + + expect(localStorageMock.setItem).toHaveBeenCalledWith('themeMode', 'dark') + }) + }) + + describe('system mode', () => { + it('должен использовать dark когда prefers-color-scheme: dark', () => { + localStorageMock.getItem.mockReturnValue('system') + useMediaQueryMock.mockReturnValue(true) // prefers dark + + const { result } = renderHook(() => useThemeContext(), { wrapper }) + + // mode должен быть 'system', но тема должна быть dark + expect(result.current.mode).toBe('system') + }) + + it('должен использовать light когда prefers-color-scheme: light', () => { + localStorageMock.getItem.mockReturnValue('system') + useMediaQueryMock.mockReturnValue(false) // prefers light + + const { result } = renderHook(() => useThemeContext(), { wrapper }) + + expect(result.current.mode).toBe('system') + }) + }) + + describe('localStorage persistence', () => { + it('должен сохранять mode в localStorage при инициализации', () => { + localStorageMock.getItem.mockReturnValue(null) + + renderHook(() => useThemeContext(), { wrapper }) + + expect(localStorageMock.setItem).toHaveBeenCalledWith('themeMode', 'system') + }) + + it('должен читать mode из localStorage', () => { + localStorageMock.getItem.mockReturnValue('dark') + + renderHook(() => useThemeContext(), { wrapper }) + + expect(localStorageMock.getItem).toHaveBeenCalledWith('themeMode') + }) + }) + + describe('mode values', () => { + it('должен поддерживать light mode', () => { + localStorageMock.getItem.mockReturnValue('light') + + const { result } = renderHook(() => useThemeContext(), { wrapper }) + + expect(result.current.mode).toBe('light') + }) + + it('должен поддерживать dark mode', () => { + localStorageMock.getItem.mockReturnValue('dark') + + const { result } = renderHook(() => useThemeContext(), { wrapper }) + + expect(result.current.mode).toBe('dark') + }) + + it('должен поддерживать system mode', () => { + localStorageMock.getItem.mockReturnValue('system') + + const { result } = renderHook(() => useThemeContext(), { wrapper }) + + expect(result.current.mode).toBe('system') + }) + }) +}) diff --git a/client/test/__mocks__/@mui/icons-material.tsx b/client/test/__mocks__/@mui/icons-material.tsx new file mode 100644 index 0000000..77c0b90 --- /dev/null +++ b/client/test/__mocks__/@mui/icons-material.tsx @@ -0,0 +1,298 @@ +// Mock для всех MUI иконок +// Этот файл автоматически используется vitest для мока @mui/icons-material + +import { forwardRef } from 'react' + +// Создаем универсальный мок для любой иконки +const IconMock = forwardRef((props, ref) => { + return ( + + + + ) +}) + +IconMock.displayName = 'IconMock' + +// Экспортируем все возможные иконки как один и тот же мок +export const GitHub = IconMock +export const YouTube = IconMock +export const Telegram = IconMock +export const Brightness7 = IconMock +export const Brightness4 = IconMock +export const BrightnessAuto = IconMock +export const Logout = IconMock +export const HelpOutline = IconMock +export const Menu = IconMock +export const People = IconMock +export const Settings = IconMock +export const Dns = IconMock +export const SwapHoriz = IconMock +export const Delete = IconMock +export const Add = IconMock +export const Terminal = IconMock +export const CheckCircle = IconMock +export const Error = IconMock +export const LinkIcon = IconMock +export const OpenInNew = IconMock +export const ContentCopy = IconMock +export const Router = IconMock +export const Edit = IconMock +export const MoreVert = IconMock +export const Remove = IconMock +export const Refresh = IconMock +export const Search = IconMock +export const FilterList = IconMock +export const RefreshTwoTone = IconMock +export const Warning = IconMock +export const Info = IconMock +export const Close = IconMock +export const Check = IconMock +export const ArrowDownward = IconMock +export const ArrowUpward = IconMock +export const MoreHoriz = IconMock +export const ContentPaste = IconMock +export const QrCode = IconMock +export const Usb = IconMock +export const VpnKey = IconMock +export const Security = IconMock +export const Speed = IconMock +export const Timeline = IconMock +export const Assessment = IconMock +export const SettingsApplications = IconMock +export const CloudDownload = IconMock +export const CloudUpload = IconMock +export const Folder = IconMock +export const FileCopy = IconMock +export const Save = IconMock +export const Print = IconMock +export const DeleteOutline = IconMock +export const Restore = IconMock +export const History = IconMock +export const Schedule = IconMock +export const AccessTime = IconMock +export const Today = IconMock +export const Event = IconMock +export const Notifications = IconMock +export const AccountCircle = IconMock +export const Person = IconMock +export const Group = IconMock +export const Public = IconMock +export const Language = IconMock +export const Translate = IconMock +export const Star = IconMock +export const Favorite = IconMock +export const Home = IconMock +export const LocationOn = IconMock +export const Place = IconMock +export const Email = IconMock +export const Phone = IconMock +export const Chat = IconMock +export const Message = IconMock +export const Forum = IconMock +export const Share = IconMock +export const Send = IconMock +export const Inbox = IconMock +export const Drafts = IconMock +export const Mail = IconMock +export const Markunread = IconMock +export const Lock = IconMock +export const LockOpen = IconMock +export const Unlock = IconMock +export const Visibility = IconMock +export const VisibilityOff = IconMock +export const Eye = IconMock +export const EyeOff = IconMock +export const ToggleOn = IconMock +export const ToggleOff = IconMock +export const RadioButtonChecked = IconMock +export const RadioButtonUnchecked = IconMock +export const CheckBox = IconMock +export const CheckBoxOutlineBlank = IconMock +export const IndeterminateCheckBox = IconMock +export const PlusOne = IconMock +export const ThumbUp = IconMock +export const ThumbDown = IconMock +export const Whatshot = IconMock +export const FavoriteBorder = IconMock +export const StarBorder = IconMock +export const Bookmark = IconMock +export const BookmarkBorder = IconMock +export const Bookmarks = IconMock +export const TurnedIn = IconMock +export const TurnedInNot = IconMock +export const Label = IconMock +export const LabelImportant = IconMock +export const Grade = IconMock +export const Done = IconMock +export const Clear = IconMock +export const Block = IconMock +export const Ban = IconMock +export const Stop = IconMock +export const Pause = IconMock +export const PlayArrow = IconMock +export const FastForward = IconMock +export const FastRewind = IconMock +export const SkipNext = IconMock +export const SkipPrevious = IconMock +export const FiberManualRecord = IconMock +export const Circle = IconMock +export const Square = IconMock +export const Rectangle = IconMock +export const Triangle = IconMock +export const NavigateNext = IconMock +export const NavigateBefore = IconMock +export const ChevronRight = IconMock +export const ChevronLeft = IconMock +export const ExpandMore = IconMock +export const ExpandLess = IconMock +export const UnfoldMore = IconMock +export const UnfoldLess = IconMock +export const ArrowRight = IconMock +export const ArrowLeft = IconMock +export const ArrowBack = IconMock +export const ArrowForward = IconMock +export const ArrowDropDown = IconMock +export const ArrowDropUp = IconMock +export const Expand = IconMock +export const SubdirectoryArrowRight = IconMock +export const SubdirectoryArrowLeft = IconMock +export const FileDownload = IconMock +export const FileUpload = IconMock +export const Attachment = IconMock +export const Link = IconMock +export const InsertLink = IconMock +export const Photo = IconMock +export const Image = IconMock +export const PictureAsPdf = IconMock +export const ImageIcon = IconMock +export const CameraAlt = IconMock +export const Videocam = IconMock +export const Movie = IconMock +export const MusicNote = IconMock +export const Mic = IconMock +export const VolumeUp = IconMock +export const VolumeOff = IconMock +export const Headset = IconMock +export const Headphones = IconMock +export const Speaker = IconMock +export const Radio = IconMock +export const Podcasts = IconMock +export const Tv = IconMock +export const DesktopWindows = IconMock +export const Laptop = IconMock +export const Computer = IconMock +export const Tablet = IconMock +export const Smartphone = IconMock +export const PhoneIphone = IconMock +export const PhoneAndroid = IconMock +export const Devices = IconMock +export const SmartDisplay = IconMock +export const Monitor = IconMock +export const ScreenShare = IconMock +export const StopScreenShare = IconMock +export const PresentToAll = IconMock +export const Cast = IconMock +export const CastConnected = IconMock +export const CastForEducation = IconMock +export const Wifi = IconMock +export const WifiOff = IconMock +export const NetworkWifi = IconMock +export const NetworkCell = IconMock +export const SignalCellular4Bar = IconMock +export const SignalWifi4Bar = IconMock +export const Bluetooth = IconMock +export const BluetoothConnected = IconMock +export const BluetoothDisabled = IconMock +export const GpsFixed = IconMock +export const GpsNotFixed = IconMock +export const LocationSearching = IconMock +export const MyLocation = IconMock +export const Navigation = IconMock +export const NearMe = IconMock +export const Directions = IconMock +export const DirectionsCar = IconMock +export const DirectionsBus = IconMock +export const DirectionsTrain = IconMock +export const DirectionsBike = IconMock +export const DirectionsWalk = IconMock +export const DirectionsRun = IconMock +export const Flight = IconMock +export const LocalAirport = IconMock +export const Hotel = IconMock +export const Restaurant = IconMock +export const LocalCafe = IconMock +export const LocalBar = IconMock +export const LocalPizza = IconMock +export const BrunchDining = IconMock +export const DinnerDining = IconMock +export const LunchDining = IconMock +export const Nightlife = IconMock +export const LocalHospital = IconMock +export const LocalPharmacy = IconMock +export const ShoppingBag = IconMock +export const ShoppingCart = IconMock +export const ShoppingBasket = IconMock +export const Store = IconMock +export const Shop = IconMock +export const Storefront = IconMock +export const LocalMall = IconMock +export const AccountBalance = IconMock +export const Business = IconMock +export const CorporateFare = IconMock +export const Work = IconMock +export const MeetingRoom = IconMock +export const Gite = IconMock +export const House = IconMock +export const Cottage = IconMock +export const Apartment = IconMock +export const Villa = IconMock +export const OtherHouses = IconMock +export const Foundation = IconMock +export const Fence = IconMock +export const Yard = IconMock +export const Pool = IconMock +export const HotTub = IconMock +export const Spa = IconMock +export const FitnessCenter = IconMock +export const SportsGymnasium = IconMock +export const SportsBasketball = IconMock +export const SportsFootball = IconMock +export const SportsSoccer = IconMock +export const SportsTennis = IconMock +export const SportsVolleyball = IconMock +export const SportsBaseball = IconMock +export const SportsCricket = IconMock +export const SportsGolf = IconMock +export const SportsHockey = IconMock +export const SportsMma = IconMock +export const SportsMotorsports = IconMock +export const SportsRugby = IconMock +export const SportsScore = IconMock +export const SportsHandball = IconMock +export const SportsKabaddi = IconMock +export const Rowing = IconMock +export const Surfing = IconMock +export const Kitesurfing = IconMock +export const Snowboarding = IconMock +export const DownhillSkiing = IconMock +export const Snowshoeing = IconMock +export const IceSkating = IconMock +export const Curling = IconMock +export const Sailing = IconMock +export const Kayaking = IconMock +export const Rafting = IconMock +export const ScubaDiving = IconMock +export const Diving = IconMock +export const Fishing = IconMock +export const Hiking = IconMock +export const RunningWithErrors = IconMock + +// Экспорт по умолчанию +export default IconMock diff --git a/client/test/__mocks__/@mui/icons-material/index.jsx b/client/test/__mocks__/@mui/icons-material/index.jsx new file mode 100644 index 0000000..f4b4b69 --- /dev/null +++ b/client/test/__mocks__/@mui/icons-material/index.jsx @@ -0,0 +1,70 @@ +// Mock для всех MUI иконок +const createIconMock = (name) => { + const IconMock = (props) => { + return + } + IconMock.displayName = name + return IconMock +} + +// Экспортируем все иконки динамически +const icons = [ + 'GitHub', 'YouTube', 'Telegram', 'Brightness7', 'Brightness4', 'BrightnessAuto', + 'Logout', 'HelpOutline', 'Menu', 'People', 'Settings', 'Dns', 'SwapHoriz', + 'Delete', 'Add', 'Terminal', 'CheckCircle', 'Error', 'LinkIcon', 'OpenInNew', + 'ContentCopy', 'Router', 'Edit', 'MoreVert', 'Remove', 'Refresh', 'Search', + 'FilterList', 'RefreshTwoTone', 'Warning', 'Info', 'Close', 'Check', + 'ArrowDownward', 'ArrowUpward', 'MoreHoriz', 'ContentPaste', 'QrCode', 'Usb', + 'VpnKey', 'Security', 'Speed', 'Timeline', 'Assessment', 'SettingsApplications', + 'CloudDownload', 'CloudUpload', 'Folder', 'FileCopy', 'Save', 'Print', + 'DeleteOutline', 'Restore', 'History', 'Schedule', 'AccessTime', 'Today', + 'Event', 'Notifications', 'AccountCircle', 'Person', 'Group', 'Public', + 'Language', 'Translate', 'Star', 'Favorite', 'Home', 'LocationOn', 'Place', + 'Email', 'Phone', 'Chat', 'Message', 'Forum', 'Share', 'Send', 'Inbox', + 'Drafts', 'Mail', 'Markunread', 'Lock', 'LockOpen', 'Unlock', 'Visibility', + 'VisibilityOff', 'Eye', 'EyeOff', 'ToggleOn', 'ToggleOff', 'RadioButtonChecked', + 'RadioButtonUnchecked', 'CheckBox', 'CheckBoxOutlineBlank', 'IndeterminateCheckBox', + 'PlusOne', 'ThumbUp', 'ThumbDown', 'Whatshot', 'FavoriteBorder', 'StarBorder', + 'Bookmark', 'BookmarkBorder', 'Bookmarks', 'TurnedIn', 'TurnedInNot', 'Label', + 'LabelImportant', 'Grade', 'Done', 'Clear', 'Block', 'Ban', 'Stop', 'Pause', + 'PlayArrow', 'FastForward', 'FastRewind', 'SkipNext', 'SkipPrevious', + 'FiberManualRecord', 'Circle', 'Square', 'Rectangle', 'Triangle', 'NavigateNext', + 'NavigateBefore', 'ChevronRight', 'ChevronLeft', 'ExpandMore', 'ExpandLess', + 'UnfoldMore', 'UnfoldLess', 'ArrowRight', 'ArrowLeft', 'ArrowBack', 'ArrowForward', + 'ArrowDropDown', 'ArrowDropUp', 'Expand', 'SubdirectoryArrowRight', + 'SubdirectoryArrowLeft', 'FileDownload', 'FileUpload', 'Attachment', 'Link', + 'InsertLink', 'Photo', 'Image', 'PictureAsPdf', 'ImageIcon', 'CameraAlt', + 'Videocam', 'Movie', 'MusicNote', 'Mic', 'VolumeUp', 'VolumeOff', 'Headset', + 'Headphones', 'Speaker', 'Radio', 'Podcasts', 'Tv', 'DesktopWindows', 'Laptop', + 'Computer', 'Tablet', 'Smartphone', 'PhoneIphone', 'PhoneAndroid', 'Devices', + 'SmartDisplay', 'Monitor', 'ScreenShare', 'StopScreenShare', 'PresentToAll', + 'Cast', 'CastConnected', 'CastForEducation', 'Wifi', 'WifiOff', 'NetworkWifi', + 'NetworkCell', 'SignalCellular4Bar', 'SignalWifi4Bar', 'Bluetooth', + 'BluetoothConnected', 'BluetoothDisabled', 'GpsFixed', 'GpsNotFixed', + 'LocationSearching', 'MyLocation', 'Navigation', 'NearMe', 'Directions', + 'DirectionsCar', 'DirectionsBus', 'DirectionsTrain', 'DirectionsBike', + 'DirectionsWalk', 'DirectionsRun', 'Flight', 'LocalAirport', 'Hotel', + 'Restaurant', 'LocalCafe', 'LocalBar', 'LocalPizza', 'BrunchDining', + 'DinnerDining', 'LunchDining', 'Nightlife', 'LocalHospital', 'LocalPharmacy', + 'ShoppingBag', 'ShoppingCart', 'ShoppingBasket', 'Store', 'Shop', 'Storefront', + 'LocalMall', 'AccountBalance', 'Business', 'CorporateFare', 'Work', + 'MeetingRoom', 'Gite', 'House', 'Cottage', 'Apartment', 'Villa', 'OtherHouses', + 'Foundation', 'Fence', 'Yard', 'Pool', 'HotTub', 'Spa', 'FitnessCenter', + 'SportsGymnasium', 'SportsBasketball', 'SportsFootball', 'SportsSoccer', + 'SportsTennis', 'SportsVolleyball', 'SportsBaseball', 'SportsCricket', + 'SportsGolf', 'SportsHockey', 'SportsMma', 'SportsMotorsports', 'SportsRugby', + 'SportsScore', 'SportsHandball', 'SportsKabaddi', 'Rowing', 'Surfing', + 'Kitesurfing', 'Snowboarding', 'DownhillSkiing', 'Snowshoeing', 'IceSkating', + 'Curling', 'Sailing', 'Kayaking', 'Rafting', 'ScubaDiving', 'Diving', 'Fishing', + 'Hiking', 'RunningWithErrors', 'PlayCircleFilled', 'PauseCircleFilled', + 'CheckCircle', 'Dns' +] + +// Создаем экспорт для каждой иконки +const exportsObj = {} +icons.forEach(name => { + exportsObj[name] = createIconMock(name) +}) + +module.exports = exportsObj +module.exports.default = createIconMock('DefaultIcon') diff --git a/client/test/__mocks__/@mui/icons-material/package.json b/client/test/__mocks__/@mui/icons-material/package.json new file mode 100644 index 0000000..ccb7f8c --- /dev/null +++ b/client/test/__mocks__/@mui/icons-material/package.json @@ -0,0 +1,5 @@ +{ + "name": "@mui/icons-material", + "main": "index.js", + "module": "index.js" +} diff --git a/client/test/api.spec.ts b/client/test/api.spec.ts new file mode 100644 index 0000000..b0386d4 --- /dev/null +++ b/client/test/api.spec.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Мок для localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { store[key] = value }), + removeItem: vi.fn((key: string) => { delete store[key] }), + clear: vi.fn(() => { store = {} }), + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}) + +// Динамический импорт после настройки моков +let api: typeof import('@/api').default + +describe('api', () => { + beforeEach(async () => { + vi.clearAllMocks() + // Очищаем модуль перед каждым тестом + await vi.resetModules() + const module = await import('@/api') + api = module.default + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('API instance', () => { + it('должен быть создан с baseURL /api', () => { + expect(api.defaults.baseURL).toBe('/api') + }) + }) + + describe('Request interceptor', () => { + it('должен добавлять токен из localStorage в заголовок Authorization', async () => { + localStorageMock.getItem.mockReturnValue('test-token') + + const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} })) + api.defaults.adapter = mockAdapter + + await api.get('/test') + + const config = mockAdapter.mock.calls[0][0] + expect(config.headers.get('Authorization')).toBe('Bearer test-token') + }) + + it('не должен добавлять токен если он отсутствует', async () => { + localStorageMock.getItem.mockReturnValue(null) + + const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} })) + api.defaults.adapter = mockAdapter + + await api.get('/test') + + const config = mockAdapter.mock.calls[0][0] + expect(config.headers.get('Authorization')).toBeFalsy() + }) + + it('должен логировать запрос с существующим токеном', async () => { + localStorageMock.getItem.mockReturnValue('test-token') + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} })) + api.defaults.adapter = mockAdapter + + await api.get('/test') + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Token: EXISTS'), expect.any(String)) + consoleSpy.mockRestore() + }) + + it('должен логировать запрос без токена', async () => { + localStorageMock.getItem.mockReturnValue(null) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} })) + api.defaults.adapter = mockAdapter + + await api.get('/test') + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Token: NULL'), expect.any(String)) + consoleSpy.mockRestore() + }) + }) + + describe('Response interceptor', () => { + it('должен логировать успешный ответ', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const mockAdapter = vi.fn(() => + Promise.resolve({ + data: { result: 'ok' }, + status: 200, + headers: {}, + config: { method: 'get', url: '/test' } + }) + ) + api.defaults.adapter = mockAdapter + + const response = await api.get('/test') + + expect(response.status).toBe(200) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('200 OK'), expect.any(String)) + consoleSpy.mockRestore() + }) + + it('должен логировать ошибку с status кодом', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const mockAdapter = vi.fn(() => + Promise.reject({ + response: { status: 401, data: { message: 'Unauthorized' } }, + config: { method: 'get', url: '/test' } + }) + ) + api.defaults.adapter = mockAdapter + + await expect(api.get('/test')).rejects.toThrow() + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('ERROR 401: Unauthorized'), + expect.any(String) + ) + consoleSpy.mockRestore() + }) + + it('должен логировать сетевую ошибку без status', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const mockAdapter = vi.fn(() => + Promise.reject({ + message: 'Network Error', + config: { method: 'get', url: '/test' } + }) + ) + api.defaults.adapter = mockAdapter + + await expect(api.get('/test')).rejects.toThrow() + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('ERROR NETWORK: Network Error'), + expect.any(String) + ) + consoleSpy.mockRestore() + }) + + it('должен возвращать сообщение из response.data.message', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const mockAdapter = vi.fn(() => + Promise.reject({ + response: { status: 400, data: { message: 'Bad Request' } }, + config: { method: 'post', url: '/create' } + }) + ) + api.defaults.adapter = mockAdapter + + await expect(api.post('/create')).rejects.toThrow() + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Bad Request'), + expect.any(String) + ) + consoleSpy.mockRestore() + }) + }) +}) diff --git a/client/test/auth/AuthContext.spec.tsx b/client/test/auth/AuthContext.spec.tsx new file mode 100644 index 0000000..d072677 --- /dev/null +++ b/client/test/auth/AuthContext.spec.tsx @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { AuthProvider, useAuth } from '@/auth/AuthContext' +import { ReactNode } from 'react' +import api from '@/api' + +// Мокаем localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value + }), + removeItem: vi.fn((key: string) => { + delete store[key] + }), + clear: vi.fn(() => { + store = {} + }), + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}) + +// Мокаем api.post для logout +vi.mock('@/api', () => ({ + default: { + post: vi.fn().mockResolvedValue({ data: { success: true } }), + interceptors: { + request: { use: vi.fn(), eject: vi.fn() }, + response: { use: vi.fn(), eject: vi.fn() }, + }, + }, +})) + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +) + +describe('AuthContext', () => { + const originalLocation = window.location + + beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + + Object.defineProperty(window, 'location', { + value: { + href: 'http://localhost/', + }, + writable: true, + configurable: true, + }) + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }) + }) + + describe('useAuth', () => { + it('должен выбрасывать ошибку при использовании вне AuthProvider', () => { + // Отключаем console.error для этого теста + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => { + renderHook(() => useAuth()) + }).toThrow('useAuth must be used within an AuthProvider') + + consoleSpy.mockRestore() + }) + }) + + describe('initial state', () => { + it('должен инициализироваться с token из localStorage', () => { + localStorageMock.getItem.mockReturnValue('test-token-123') + + const { result } = renderHook(() => useAuth(), { wrapper }) + + expect(result.current.token).toBe('test-token-123') + expect(result.current.isAuthenticated).toBe(true) + expect(localStorageMock.getItem).toHaveBeenCalledWith('token') + }) + + it('должен инициализироваться с null если token отсутствует в localStorage', () => { + localStorageMock.getItem.mockReturnValue(null) + + const { result } = renderHook(() => useAuth(), { wrapper }) + + expect(result.current.token).toBe(null) + expect(result.current.isAuthenticated).toBe(false) + }) + }) + + describe('login', () => { + it('должен сохранять токен в localStorage и state', () => { + const { result } = renderHook(() => useAuth(), { wrapper }) + + act(() => { + result.current.login('new-token-456') + }) + + expect(result.current.token).toBe('new-token-456') + expect(result.current.isAuthenticated).toBe(true) + expect(localStorageMock.setItem).toHaveBeenCalledWith('token', 'new-token-456') + }) + + it('должен обновлять isAuthenticated после login', () => { + const { result } = renderHook(() => useAuth(), { wrapper }) + + expect(result.current.isAuthenticated).toBe(false) + + act(() => { + result.current.login('another-token') + }) + + expect(result.current.isAuthenticated).toBe(true) + }) + }) + + describe('logout', () => { + it('должен удалять токен из localStorage и state', async () => { + localStorageMock.getItem.mockReturnValue('existing-token') + + const { result } = renderHook(() => useAuth(), { wrapper }) + + expect(result.current.token).toBe('existing-token') + + await act(async () => { + await result.current.logout() + }) + + expect(result.current.token).toBe(null) + expect(result.current.isAuthenticated).toBe(false) + expect(localStorageMock.removeItem).toHaveBeenCalledWith('token') + expect(api.post).toHaveBeenCalledWith('/auth/logout') + }) + + it('должен корректно работать logout когда token уже null', async () => { + const { result } = renderHook(() => useAuth(), { wrapper }) + + await act(async () => { + await result.current.logout() + }) + + expect(result.current.token).toBe(null) + expect(result.current.isAuthenticated).toBe(false) + expect(localStorageMock.removeItem).toHaveBeenCalledWith('token') + }) + }) + + describe('isAuthenticated', () => { + it('должен возвращать true когда token существует', () => { + localStorageMock.getItem.mockReturnValue('valid-token') + + const { result } = renderHook(() => useAuth(), { wrapper }) + + expect(result.current.isAuthenticated).toBe(true) + }) + + it('должен возвращать false когда token null', () => { + localStorageMock.getItem.mockReturnValue(null) + + const { result } = renderHook(() => useAuth(), { wrapper }) + + expect(result.current.isAuthenticated).toBe(false) + }) + + it('должен возвращать false когда token пустая строка', () => { + localStorageMock.getItem.mockReturnValue('') + + const { result } = renderHook(() => useAuth(), { wrapper }) + + expect(result.current.isAuthenticated).toBe(false) + }) + }) + + describe('context methods', () => { + it('должен предоставлять метод login', () => { + const { result } = renderHook(() => useAuth(), { wrapper }) + + expect(result.current.login).toBeDefined() + expect(typeof result.current.login).toBe('function') + }) + + it('должен предоставлять метод logout', () => { + const { result } = renderHook(() => useAuth(), { wrapper }) + + expect(result.current.logout).toBeDefined() + expect(typeof result.current.logout).toBe('function') + }) + }) + + describe('multiple login/logout cycles', () => { + it('должен корректно обрабатывать несколько циклов login/logout', async () => { + const { result } = renderHook(() => useAuth(), { wrapper }) + + // Первый цикл + act(() => { + result.current.login('token-1') + }) + expect(result.current.token).toBe('token-1') + + await act(async () => { + await result.current.logout() + }) + expect(result.current.token).toBe(null) + + // Второй цикл + act(() => { + result.current.login('token-2') + }) + expect(result.current.token).toBe('token-2') + + await act(async () => { + await result.current.logout() + }) + expect(result.current.token).toBe(null) + + // Третий цикл + act(() => { + result.current.login('token-3') + }) + expect(result.current.token).toBe('token-3') + }) + }) +}) diff --git a/client/test/auth/AxiosInterceptor.spec.tsx b/client/test/auth/AxiosInterceptor.spec.tsx new file mode 100644 index 0000000..152734b --- /dev/null +++ b/client/test/auth/AxiosInterceptor.spec.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { AxiosInterceptor } from '../../src/auth/AxiosInterceptor' +import { AuthProvider } from '../../src/auth/AuthContext' +import { ThemeProvider } from '../../src/ThemeContext' +import { Logger } from '../../src/utils/logger' + +const mockNavigate = vi.fn() +const mockLogout = vi.fn() + +// Мок api с интерсепторами - используем vi.hoisted для подъёма +const mocks = vi.hoisted(() => ({ + responseUse: vi.fn(), + responseEject: vi.fn(), +})) + +vi.mock('../../src/api', () => ({ + default: { + interceptors: { + request: { + use: vi.fn(), + eject: vi.fn(), + }, + response: { + use: mocks.responseUse, + eject: mocks.responseEject, + }, + }, + }, +})) + +// Мок react-router-dom +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...(actual as object), + useNavigate: () => mockNavigate, + useLocation: () => ({ pathname: '/subscriptions' }), + } +}) + +// Мок AuthContext +vi.mock('../../src/auth/AuthContext', async () => { + const actual = await vi.importActual('../../src/auth/AuthContext') + return { + ...(actual as object), + useAuth: () => ({ logout: mockLogout }), + } +}) + +vi.mock('../../src/utils/logger', () => ({ + Logger: { + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})) + +describe('AxiosInterceptor', () => { + beforeEach(() => { + vi.clearAllMocks() + mockNavigate.mockClear() + mockLogout.mockClear() + vi.mocked(Logger.warn).mockClear() + vi.mocked(Logger.debug).mockClear() + mocks.responseUse.mockClear() + mocks.responseEject.mockClear() + }) + + it('должен рендериться без ошибок и возвращать null', () => { + const { container } = render( + + + + + + + + ) + expect(container.firstChild).toBeNull() + }) + + it('должен регистрировать interceptor при монтировании', async () => { + render( + + + + + + + + ) + + await waitFor(() => { + expect(mocks.responseUse).toHaveBeenCalled() + }) + + // Проверяем что был зарегистрирован обработчик + const interceptorCall = vi.mocked(mocks.responseUse).mock.calls[0] + expect(interceptorCall).toBeDefined() + expect(typeof interceptorCall?.[0]).toBe('function') // success handler + expect(typeof interceptorCall?.[1]).toBe('function') // error handler + }) + + it('должен пропускать успешные ответы', () => { + const successHandler = (response: unknown) => response + + const response = { data: { test: 'value' }, status: 200 } + const result = successHandler(response) + + expect(result).toEqual(response) + }) + + it('должен отклонять ошибки не 401', async () => { + const errorHandler = (error: unknown) => Promise.reject(error) + + const errorResponse = { response: { status: 500 } } + + await expect(errorHandler(errorResponse)).rejects.toEqual(errorResponse) + }) + + it('должен удалять interceptor при размонтировании', async () => { + const { unmount } = render( + + + + + + + + ) + + await waitFor(() => { + expect(mocks.responseUse).toHaveBeenCalled() + }) + + unmount() + + expect(mocks.responseEject).toHaveBeenCalled() + }) + + it('должен обрабатывать 401 ошибки', async () => { + render( + + + + + + + + ) + + await waitFor(() => { + expect(mocks.responseUse).toHaveBeenCalled() + }) + + const interceptorCall = vi.mocked(mocks.responseUse).mock.calls[0] + const errorHandler = interceptorCall?.[1] + + if (errorHandler) { + try { + await errorHandler({ response: { status: 401 } }) + } catch { + // Ожидаем что ошибка будет проброшена дальше + } + } + + // Проверяем что logout и navigate были вызваны + expect(mockLogout).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('/login') + }) +}) diff --git a/client/test/auth/PublicRoute.spec.tsx b/client/test/auth/PublicRoute.spec.tsx new file mode 100644 index 0000000..3ca8806 --- /dev/null +++ b/client/test/auth/PublicRoute.spec.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import PublicRoute from '@/auth/PublicRoute' +import { AuthProvider } from '@/auth/AuthContext' + +// Мокаем localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value + }), + removeItem: vi.fn((key: string) => { + delete store[key] + }), + clear: vi.fn(() => { + store = {} + }), + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}) + +describe('PublicRoute', () => { + beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + }) + + it('должен рендерить Outlet когда пользователь не авторизован', () => { + localStorageMock.getItem.mockReturnValue(null) + + render( + + + + }> + Login Page} /> + + + + + ) + + expect(screen.getByTestId('login-page')).toBeInTheDocument() + expect(screen.getByText('Login Page')).toBeInTheDocument() + }) + + it('должен перенаправлять на / когда пользователь авторизован', () => { + localStorageMock.getItem.mockReturnValue('valid-token') + + render( + + + + }> + Login Page} /> + + Home Page} /> + + + + ) + + // Должен показать главную страницу вместо login + expect(screen.getByTestId('home-page')).toBeInTheDocument() + expect(screen.getByText('Home Page')).toBeInTheDocument() + }) + + it('должен работать с несколькими публичными роутами', () => { + localStorageMock.getItem.mockReturnValue(null) + + render( + + + + }> + Login Page} /> + Register Page} /> + + + + + ) + + expect(screen.getByTestId('register-page')).toBeInTheDocument() + expect(screen.getByText('Register Page')).toBeInTheDocument() + }) + + it('должен перенаправлять авторизованного пользователя с любого публичного роута', () => { + localStorageMock.getItem.mockReturnValue('valid-token') + + render( + + + + }> + Login Page} /> + Register Page} /> + + Home Page} /> + + + + ) + + // Должен показать главную страницу + expect(screen.getByTestId('home-page')).toBeInTheDocument() + expect(screen.queryByTestId('register-page')).not.toBeInTheDocument() + }) + + it('должен использовать replace при перенаправлении', () => { + localStorageMock.getItem.mockReturnValue('valid-token') + + const { container } = render( + + + + }> + Login} /> + + Home} /> + + + + ) + + // Проверяем что рендерится Home + expect(container.textContent).toContain('Home') + }) +}) diff --git a/client/test/auth/RequireAuth.spec.tsx b/client/test/auth/RequireAuth.spec.tsx new file mode 100644 index 0000000..a006173 --- /dev/null +++ b/client/test/auth/RequireAuth.spec.tsx @@ -0,0 +1,186 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import RequireAuth from '@/auth/RequireAuth' +import { AuthProvider } from '@/auth/AuthContext' + +// Мокаем localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value + }), + removeItem: vi.fn((key: string) => { + delete store[key] + }), + clear: vi.fn(() => { + store = {} + }), + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}) + +describe('RequireAuth', () => { + beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + }) + + it('должен рендерить children когда пользователь авторизован', () => { + localStorageMock.getItem.mockReturnValue('valid-token') + + render( + + + + +
Protected Content
+ + } + /> +
+
+
+ ) + + expect(screen.getByTestId('protected-content')).toBeInTheDocument() + expect(screen.getByText('Protected Content')).toBeInTheDocument() + }) + + it('должен перенаправлять на /login когда пользователь не авторизован', () => { + localStorageMock.getItem.mockReturnValue(null) + + render( + + + + +
Protected Content
+ + } + /> + Login Page} /> +
+
+
+ ) + + expect(screen.getByTestId('login-page')).toBeInTheDocument() + expect(screen.getByText('Login Page')).toBeInTheDocument() + }) + + it('должен сохранять state.from с текущим location при перенаправлении на login', () => { + localStorageMock.getItem.mockReturnValue(null) + + render( + + + + +
Settings
+ + } + /> + Login Page} /> +
+
+
+ ) + + // Должен показать login страницу + expect(screen.getByTestId('login-page')).toBeInTheDocument() + }) + + it('должен использовать replace: true при перенаправлении', () => { + // Этот тест проверяет поведение Navigate компонента + // В реальном сценарии replace предотвращает добавление записи в историю + localStorageMock.getItem.mockReturnValue(null) + + const { container } = render( + + + + +
Protected
+ + } + /> + Login} /> +
+
+
+ ) + + // Проверяем что рендерится login + expect(container.textContent).toContain('Login') + }) + + it('должен работать с вложенными роутами', () => { + localStorageMock.getItem.mockReturnValue('valid-token') + + render( + + + + + + Profile} /> + + + } + /> + + + + ) + + expect(screen.getByTestId('profile')).toBeInTheDocument() + }) + + it('должен блокировать доступ к защищённому маршруту без авторизации', () => { + localStorageMock.getItem.mockReturnValue(null) + + render( + + + + +
Admin Panel
+ + } + /> + Login Page} /> +
+
+
+ ) + + // Контент админки не должен быть доступен + expect(screen.queryByTestId('admin-content')).not.toBeInTheDocument() + // Должна показываться страница логина + expect(screen.getByTestId('login-page')).toBeInTheDocument() + }) +}) diff --git a/client/test/components/Footer.spec.tsx b/client/test/components/Footer.spec.tsx new file mode 100644 index 0000000..de8d9de --- /dev/null +++ b/client/test/components/Footer.spec.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import Footer from '../../src/components/Footer' + +describe('Footer', () => { + const renderFooter = (props: Partial> = {}) => { + return render( + +