add auth
This commit is contained in:
112
ARCHITECTURE.md
112
ARCHITECTURE.md
@ -11,6 +11,7 @@ flowchart TB
|
||||
subgraph external [" "]
|
||||
User["👤 Пользователь<br/><i>Член команды разработки</i>"]
|
||||
AI["🤖 AI Proxy Service<br/><i>LLM для оценки задач</i>"]
|
||||
KC["🔐 Keycloak<br/><i>auth.vigdorov.ru<br/>Identity Provider</i>"]
|
||||
end
|
||||
|
||||
subgraph system ["Team Planner"]
|
||||
@ -18,11 +19,14 @@ flowchart TB
|
||||
end
|
||||
|
||||
User -->|"Управляет идеями,<br/>командой, комментариями<br/>[HTTPS]"| TP
|
||||
User -->|"Авторизация<br/>[OIDC/PKCE]"| KC
|
||||
KC -->|"JWT токены"| TP
|
||||
TP -->|"Запросы на оценку<br/>трудозатрат<br/>[HTTPS/REST]"| AI
|
||||
|
||||
style TP fill:#1168bd,color:#fff
|
||||
style User fill:#08427b,color:#fff
|
||||
style AI fill:#999,color:#fff
|
||||
style KC fill:#c92a2a,color:#fff
|
||||
```
|
||||
|
||||
### 1.2 Level 2: Container Diagram
|
||||
@ -30,6 +34,7 @@ flowchart TB
|
||||
```mermaid
|
||||
flowchart TB
|
||||
User["👤 Пользователь<br/><i>Член команды разработки</i>"]
|
||||
KC["🔐 Keycloak<br/><i>auth.vigdorov.ru</i>"]
|
||||
|
||||
subgraph TeamPlanner ["Team Planner"]
|
||||
SPA["📱 Frontend SPA<br/><i>React, TypeScript, MUI</i><br/><br/>Веб-интерфейс для<br/>работы с идеями"]
|
||||
@ -40,7 +45,9 @@ flowchart TB
|
||||
AI["🤖 AI Proxy Service<br/><i>LLM для оценки задач</i>"]
|
||||
|
||||
User -->|"Использует<br/>[HTTPS]"| SPA
|
||||
SPA -->|"API запросы<br/>[REST/WebSocket]"| API
|
||||
User <-->|"OIDC Login<br/>[Redirect]"| KC
|
||||
SPA -->|"API запросы<br/>[REST + Bearer JWT]"| API
|
||||
API -.->|"Валидация JWT<br/>[JWKS]"| KC
|
||||
API -->|"Читает/пишет<br/>[TypeORM]"| DB
|
||||
API -->|"Оценка трудозатрат<br/>[HTTPS/REST]"| AI
|
||||
|
||||
@ -49,12 +56,49 @@ flowchart TB
|
||||
style DB fill:#438dd5,color:#fff
|
||||
style User fill:#08427b,color:#fff
|
||||
style AI fill:#999,color:#fff
|
||||
style KC fill:#c92a2a,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Sequence Diagrams
|
||||
|
||||
### 2.0 Авторизация (Keycloak OIDC)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as Пользователь
|
||||
participant FE as Frontend<br/>(React)
|
||||
participant KC as Keycloak<br/>(auth.vigdorov.ru)
|
||||
participant BE as Backend<br/>(NestJS)
|
||||
|
||||
User->>FE: Открывает приложение
|
||||
FE->>FE: keycloak.init({ onLoad: 'login-required' })
|
||||
FE->>KC: Redirect на /auth (PKCE)
|
||||
KC-->>User: Форма входа
|
||||
User->>KC: Вводит логин/пароль
|
||||
KC->>KC: Проверяет credentials
|
||||
KC-->>FE: Redirect с authorization code
|
||||
FE->>KC: POST /token (code + code_verifier)
|
||||
KC-->>FE: { access_token, refresh_token }
|
||||
FE->>FE: Сохраняет токены в памяти
|
||||
FE-->>User: Показывает приложение
|
||||
|
||||
Note over FE,BE: Все последующие API запросы
|
||||
|
||||
FE->>BE: GET /api/ideas<br/>Authorization: Bearer {token}
|
||||
BE->>KC: GET /certs (JWKS, кэшируется)
|
||||
KC-->>BE: Public keys
|
||||
BE->>BE: Валидация JWT подписи
|
||||
BE-->>FE: 200 OK { data }
|
||||
|
||||
Note over FE,KC: Автообновление токена (каждые 10 сек)
|
||||
|
||||
FE->>KC: POST /token (refresh_token)
|
||||
KC-->>FE: { new_access_token }
|
||||
```
|
||||
|
||||
### 2.1 Создание идеи
|
||||
|
||||
```mermaid
|
||||
@ -986,3 +1030,69 @@ Toast notification (Snackbar):
|
||||
Нет команды: "Добавьте членов команды для AI-оценки"
|
||||
Нет результатов: "По вашему запросу ничего не найдено"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Авторизация (Keycloak)
|
||||
|
||||
### 7.1 Конфигурация Keycloak
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| URL | https://auth.vigdorov.ru |
|
||||
| Realm | `team-planner` |
|
||||
| Client ID | `team-planner-frontend` |
|
||||
| Client Type | Public (no secret) |
|
||||
| Authentication Flow | Authorization Code + PKCE |
|
||||
|
||||
### 7.2 Настройка Client в Keycloak
|
||||
|
||||
```
|
||||
Client authentication: OFF (public client)
|
||||
Standard flow: ON
|
||||
Direct access grants: OFF
|
||||
Valid redirect URIs: http://localhost:4000/*
|
||||
Web origins: http://localhost:4000
|
||||
```
|
||||
|
||||
### 7.3 Environment Variables
|
||||
|
||||
**Backend (.env):**
|
||||
```
|
||||
KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner
|
||||
```
|
||||
|
||||
**Frontend (.env):**
|
||||
```
|
||||
VITE_KEYCLOAK_URL=https://auth.vigdorov.ru
|
||||
VITE_KEYCLOAK_REALM=team-planner
|
||||
VITE_KEYCLOAK_CLIENT_ID=team-planner-frontend
|
||||
```
|
||||
|
||||
### 7.4 JWT Validation (Backend)
|
||||
|
||||
```typescript
|
||||
// Валидация через JWKS (публичные ключи)
|
||||
secretOrKeyProvider: passportJwtSecret({
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 5,
|
||||
jwksUri: `${realmUrl}/protocol/openid-connect/certs`,
|
||||
})
|
||||
|
||||
// Проверки
|
||||
issuer: KEYCLOAK_REALM_URL
|
||||
algorithms: ['RS256']
|
||||
```
|
||||
|
||||
### 7.5 Защищённые и публичные endpoints
|
||||
|
||||
| Endpoint | Доступ | Декоратор |
|
||||
|----------|--------|-----------|
|
||||
| `GET /` | Public | `@Public()` |
|
||||
| `GET /health` | Public | `@Public()` |
|
||||
| `GET /api/ideas` | Protected | — |
|
||||
| `POST /api/ideas` | Protected | — |
|
||||
| `PATCH /api/ideas/:id` | Protected | — |
|
||||
| `DELETE /api/ideas/:id` | Protected | — |
|
||||
| Все остальные | Protected | — |
|
||||
|
||||
53
CONTEXT.md
53
CONTEXT.md
@ -6,9 +6,9 @@
|
||||
|
||||
## Текущий статус
|
||||
|
||||
**Этап:** Фаза 1 (Frontend) завершена
|
||||
**Фаза MVP:** Готов к тестированию базового функционала
|
||||
**Последнее обновление:** 2025-12-31
|
||||
**Этап:** Фаза 2 — Drag & Drop ✅, Авторизация ✅, далее цвета/комментарии/команда
|
||||
**Фаза MVP:** Базовый функционал + авторизация готовы
|
||||
**Последнее обновление:** 2026-01-14
|
||||
|
||||
---
|
||||
|
||||
@ -35,6 +35,15 @@
|
||||
| 2025-12-29 | **Фаза 1:** Frontend — Удаление идей |
|
||||
| 2025-12-31 | Исправлен баг: Select в inline-редактировании закрывался при клике (MenuProps.disablePortal) |
|
||||
| 2025-12-31 | Локализация интерфейса на русский язык |
|
||||
| 2026-01-13 | **Фаза 2:** Backend — PATCH /api/ideas/reorder endpoint (ReorderIdeasDto, транзакция) |
|
||||
| 2026-01-13 | **Фаза 2:** Frontend — Drag & Drop с dnd-kit (DraggableRow, drag handle) |
|
||||
| 2026-01-13 | **Фаза 2:** Исправлены баги D&D: setNodeRef, сортировка по order, оптимистичные обновления, DragOverlay |
|
||||
| 2026-01-13 | Добавлены Selenium E2E тесты (tests/e2e/) — Фаза 1 ✅, Фаза 2 частично |
|
||||
| 2026-01-13 | **Авторизация:** Backend Auth модуль (JWT + Keycloak JWKS) |
|
||||
| 2026-01-13 | **Авторизация:** Frontend AuthProvider (keycloak-js, auto token refresh) |
|
||||
| 2026-01-14 | E2E тесты переписаны с Selenium на Playwright (tests/e2e/*.spec.ts) |
|
||||
| 2026-01-14 | **Фаза 2:** Улучшен Drag & Drop — добавлен @dnd-kit/modifiers, исправлен race condition с drag handle, restrictToVerticalAxis |
|
||||
| 2026-01-14 | **Production:** Настроен Keycloak для production (team-planner.vigdorov.ru), обновлён Dockerfile с Keycloak переменными |
|
||||
|
||||
---
|
||||
|
||||
@ -42,7 +51,7 @@
|
||||
|
||||
> Смотри [ROADMAP.md](ROADMAP.md) для полного плана разработки
|
||||
|
||||
**Сейчас:** Тестирование Фазы 1, затем Фаза 2 (Drag&Drop, цвета, комментарии)
|
||||
**Сейчас:** Фаза 2 — цветовая маркировка, комментарии, управление командой
|
||||
|
||||
---
|
||||
|
||||
@ -57,9 +66,24 @@ team-planner/
|
||||
├── ARCHITECTURE.md # Архитектура, API, UI
|
||||
├── ROADMAP.md # План разработки
|
||||
├── docker-compose.yml # PostgreSQL и сервисы
|
||||
├── tests/
|
||||
│ ├── package.json # Зависимости для тестов
|
||||
│ ├── playwright.config.ts # Конфигурация Playwright
|
||||
│ └── e2e/ # Playwright E2E тесты
|
||||
│ ├── auth.setup.ts # Авторизация для тестов (Keycloak)
|
||||
│ ├── phase1.spec.ts # Тесты Фазы 1 (11 тестов)
|
||||
│ └── phase2.spec.ts # Тесты Фазы 2 (D&D, цвета, комментарии, команда)
|
||||
├── backend/ # NestJS API
|
||||
│ ├── src/
|
||||
│ │ ├── ideas/ # Модуль идей (готов)
|
||||
│ │ ├── auth/ # Модуль авторизации ✅
|
||||
│ │ │ ├── jwt.strategy.ts # JWT валидация через JWKS
|
||||
│ │ │ ├── jwt-auth.guard.ts # Глобальный guard
|
||||
│ │ │ └── decorators/public.decorator.ts # @Public() для открытых endpoints
|
||||
│ │ ├── ideas/ # Модуль идей (готов + reorder)
|
||||
│ │ │ ├── dto/
|
||||
│ │ │ │ └── reorder-ideas.dto.ts # DTO для изменения порядка
|
||||
│ │ │ ├── ideas.controller.ts # PATCH /ideas/reorder
|
||||
│ │ │ └── ideas.service.ts # reorder() с транзакцией
|
||||
│ │ ├── team/ # Модуль команды (Фаза 2)
|
||||
│ │ ├── comments/ # Модуль комментариев (Фаза 2)
|
||||
│ │ └── ai/ # AI-оценка (Фаза 3)
|
||||
@ -67,14 +91,20 @@ team-planner/
|
||||
└── frontend/ # React приложение
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── IdeasTable/ # Таблица идей с inline-редактированием
|
||||
│ │ ├── AuthProvider/ # Keycloak авторизация ✅
|
||||
│ │ ├── IdeasTable/
|
||||
│ │ │ ├── IdeasTable.tsx # Таблица с DndContext
|
||||
│ │ │ ├── DraggableRow.tsx # Сортируемая строка (useSortable)
|
||||
│ │ │ ├── columns.tsx # Колонки + drag handle
|
||||
│ │ │ └── ...
|
||||
│ │ ├── IdeasFilters/ # Фильтры
|
||||
│ │ └── CreateIdeaModal/ # Модалка создания
|
||||
│ ├── hooks/
|
||||
│ │ └── useIdeas.ts # React Query хуки
|
||||
│ │ └── useIdeas.ts # React Query хуки + useReorderIdeas
|
||||
│ ├── services/
|
||||
│ │ ├── api.ts # Axios instance
|
||||
│ │ └── ideas.ts # API методы для идей
|
||||
│ │ ├── api.ts # Axios + auth interceptors
|
||||
│ │ ├── keycloak.ts # Keycloak instance ✅
|
||||
│ │ └── ideas.ts # API методы + reorder()
|
||||
│ ├── store/
|
||||
│ │ └── ideas.ts # Zustand store
|
||||
│ └── types/
|
||||
@ -95,6 +125,8 @@ team-planner/
|
||||
| Drag & Drop | dnd-kit | Современный, хорошая поддержка |
|
||||
| Data Fetching | React Query | Кэширование, оптимистичные обновления |
|
||||
| Язык интерфейса | Русский | Требование проекта |
|
||||
| Авторизация | Keycloak | Внешний IdP, OIDC, редиректы |
|
||||
| E2E тесты | Playwright | Быстрее Selenium, лучше API, auto-wait |
|
||||
|
||||
---
|
||||
|
||||
@ -111,3 +143,6 @@ team-planner/
|
||||
- Многопользовательский режим НЕ нужен
|
||||
- Экспорт и интеграции НЕ нужны
|
||||
- Warning о React Compiler и TanStack Table можно игнорировать
|
||||
- **Drag & Drop:** dnd-kit с useSortable + @dnd-kit/modifiers (restrictToVerticalAxis), DragHandle через React Context, CSS.Translate для совместимости с таблицами, reorder через транзакцию
|
||||
- **Keycloak:** auth.vigdorov.ru, realm `team-planner`, client `team-planner-frontend`
|
||||
- **Production URL:** https://team-planner.vigdorov.ru (добавлен в Valid redirect URIs и Web origins клиента Keycloak)
|
||||
|
||||
@ -138,6 +138,15 @@
|
||||
- Валидация входных данных
|
||||
- Rate limiting для AI-запросов
|
||||
|
||||
### Авторизация
|
||||
- **Keycloak** (auth.vigdorov.ru) — внешний Identity Provider
|
||||
- Авторизация через редиректы на стандартную форму Keycloak
|
||||
- Authorization Code Flow + PKCE
|
||||
- JWT токены с валидацией через JWKS
|
||||
- Автоматическое обновление токенов
|
||||
- Защита всех API endpoints (кроме /health)
|
||||
- Роли и права доступа НЕ требуются — просто аутентификация
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
148
ROADMAP.md
148
ROADMAP.md
@ -9,79 +9,116 @@
|
||||
|
||||
| Фаза | Название | Статус | Описание |
|
||||
|------|----------|--------|----------|
|
||||
| 0 | Инициализация | ⏳ В процессе | Настройка проектов, инфраструктура |
|
||||
| 1 | Базовый функционал | ⏸️ Ожидает | CRUD идей, таблица, редактирование |
|
||||
| 2 | Расширенный функционал | ⏸️ Ожидает | Drag&Drop, цвета, комментарии, команда |
|
||||
| 0 | Инициализация | ✅ Завершена | Настройка проектов, инфраструктура |
|
||||
| 1 | Базовый функционал | ✅ Завершена | CRUD идей, таблица, редактирование |
|
||||
| 1.5 | Авторизация | ✅ Завершена | Keycloak, JWT, защита API |
|
||||
| 2 | Расширенный функционал | 🔄 В процессе | Drag&Drop ✅, цвета, комментарии, команда |
|
||||
| 3 | AI-интеграция | ⏸️ Ожидает | Оценка времени, рекомендации |
|
||||
|
||||
---
|
||||
|
||||
## Фаза 0: Инициализация
|
||||
## Фаза 0: Инициализация ✅
|
||||
|
||||
### Backend
|
||||
- [ ] Создать NestJS проект (`nest new backend`)
|
||||
- [ ] Настроить TypeORM + PostgreSQL
|
||||
- [ ] Создать docker-compose для PostgreSQL
|
||||
- [ ] Настроить базовую структуру модулей
|
||||
- [ ] Добавить глобальную валидацию (class-validator)
|
||||
- [ ] Настроить CORS
|
||||
- [x] Создать NestJS проект (`nest new backend`)
|
||||
- [x] Настроить TypeORM + PostgreSQL
|
||||
- [x] Создать docker-compose для PostgreSQL
|
||||
- [x] Настроить базовую структуру модулей
|
||||
- [x] Добавить глобальную валидацию (class-validator)
|
||||
- [x] Настроить CORS
|
||||
|
||||
### Frontend
|
||||
- [ ] Создать React проект (Vite + TypeScript)
|
||||
- [ ] Установить и настроить MUI
|
||||
- [ ] Установить Zustand
|
||||
- [ ] Установить TanStack Table
|
||||
- [ ] Установить dnd-kit
|
||||
- [ ] Настроить Axios + React Query
|
||||
- [ ] Создать базовую структуру папок
|
||||
- [x] Создать React проект (Vite + TypeScript)
|
||||
- [x] Установить и настроить MUI
|
||||
- [x] Установить Zustand
|
||||
- [x] Установить TanStack Table
|
||||
- [x] Установить dnd-kit
|
||||
- [x] Настроить Axios + React Query
|
||||
- [x] Создать базовую структуру папок
|
||||
|
||||
### Инфраструктура
|
||||
- [x] Создать общий docker-compose
|
||||
- [ ] Настроить ESLint + Prettier для обоих проектов
|
||||
- [ ] Создать общий docker-compose
|
||||
|
||||
---
|
||||
|
||||
## Фаза 1: Базовый функционал
|
||||
## Фаза 1: Базовый функционал ✅
|
||||
|
||||
### Backend — Модуль Ideas
|
||||
- [ ] Создать сущность Idea (entity)
|
||||
- [ ] Создать DTO (CreateIdeaDto, UpdateIdeaDto, QueryIdeasDto)
|
||||
- [ ] Реализовать IdeasService
|
||||
- [ ] Реализовать IdeasController
|
||||
- [ ] GET /api/ideas (с пагинацией, фильтрами, сортировкой)
|
||||
- [ ] POST /api/ideas
|
||||
- [ ] PATCH /api/ideas/:id
|
||||
- [ ] DELETE /api/ideas/:id
|
||||
- [ ] Добавить валидацию
|
||||
- [x] Создать сущность Idea (entity)
|
||||
- [x] Создать DTO (CreateIdeaDto, UpdateIdeaDto, QueryIdeasDto)
|
||||
- [x] Реализовать IdeasService
|
||||
- [x] Реализовать IdeasController
|
||||
- [x] GET /api/ideas (с пагинацией, фильтрами, сортировкой)
|
||||
- [x] POST /api/ideas
|
||||
- [x] PATCH /api/ideas/:id
|
||||
- [x] DELETE /api/ideas/:id
|
||||
- [x] Добавить валидацию
|
||||
- [ ] Написать тесты
|
||||
|
||||
### Frontend — Таблица идей
|
||||
- [ ] Создать типы (types/idea.ts)
|
||||
- [ ] Создать API-сервис (services/ideas.ts)
|
||||
- [ ] Создать Zustand store (store/ideas.ts)
|
||||
- [ ] Создать компонент IdeasTable
|
||||
- [ ] Отображение колонок
|
||||
- [ ] Пагинация
|
||||
- [ ] Сортировка (клик по заголовку)
|
||||
- [ ] Создать компоненты фильтров
|
||||
- [ ] Фильтр по статусу
|
||||
- [ ] Фильтр по приоритету
|
||||
- [ ] Фильтр по модулю
|
||||
- [ ] Текстовый поиск
|
||||
- [ ] Inline-редактирование ячеек
|
||||
- [ ] Double-click для редактирования
|
||||
- [ ] Автосохранение при blur/Enter
|
||||
- [ ] Оптимистичные обновления
|
||||
- [ ] Создать модалку создания идеи
|
||||
- [ ] Добавить skeleton loader
|
||||
- [ ] Добавить empty state
|
||||
- [x] Создать типы (types/idea.ts)
|
||||
- [x] Создать API-сервис (services/ideas.ts)
|
||||
- [x] Создать Zustand store (store/ideas.ts)
|
||||
- [x] Создать компонент IdeasTable
|
||||
- [x] Отображение колонок
|
||||
- [x] Пагинация
|
||||
- [x] Сортировка (клик по заголовку)
|
||||
- [x] Создать компоненты фильтров
|
||||
- [x] Фильтр по статусу
|
||||
- [x] Фильтр по приоритету
|
||||
- [x] Фильтр по модулю
|
||||
- [x] Текстовый поиск
|
||||
- [x] Inline-редактирование ячеек
|
||||
- [x] Double-click для редактирования
|
||||
- [x] Автосохранение при blur/Enter
|
||||
- [x] Оптимистичные обновления
|
||||
- [x] Создать модалку создания идеи
|
||||
- [x] Добавить skeleton loader
|
||||
- [x] Добавить empty state
|
||||
- [x] Удаление идей
|
||||
- [x] Локализация интерфейса на русский язык
|
||||
|
||||
---
|
||||
|
||||
## Фаза 2: Расширенный функционал
|
||||
## Фаза 1.5: Авторизация ✅
|
||||
|
||||
> **Keycloak интеграция для защиты приложения**
|
||||
|
||||
### Настройка Keycloak (auth.vigdorov.ru)
|
||||
- [x] Создать realm `team-planner`
|
||||
- [x] Создать client `team-planner-frontend` (public, PKCE)
|
||||
- [x] Настроить Valid Redirect URIs
|
||||
- [x] Создать тестового пользователя
|
||||
|
||||
### Backend — Auth модуль
|
||||
- [x] Установить passport, passport-jwt, jwks-rsa
|
||||
- [x] Создать JwtStrategy (валидация через JWKS)
|
||||
- [x] Создать JwtAuthGuard (глобальный guard)
|
||||
- [x] Создать @Public() декоратор
|
||||
- [x] Добавить env переменные (KEYCLOAK_REALM_URL)
|
||||
- [x] Защитить все endpoints (кроме /health)
|
||||
|
||||
### Frontend — AuthProvider
|
||||
- [x] Установить keycloak-js
|
||||
- [x] Создать keycloak.ts сервис
|
||||
- [x] Создать AuthProvider компонент
|
||||
- [x] onLoad: 'login-required'
|
||||
- [x] PKCE (S256)
|
||||
- [x] Автообновление токена
|
||||
- [x] Добавить interceptors в api.ts
|
||||
- [x] Authorization header
|
||||
- [x] Обработка 401
|
||||
- [x] Обернуть App в AuthProvider
|
||||
|
||||
---
|
||||
|
||||
## Фаза 2: Расширенный функционал 🔄
|
||||
|
||||
> **Текущая фаза разработки**
|
||||
|
||||
### Backend — Дополнения
|
||||
- [ ] PATCH /api/ideas/reorder (изменение порядка)
|
||||
- [x] PATCH /api/ideas/reorder (изменение порядка)
|
||||
- [ ] Модуль Comments
|
||||
- [ ] Сущность Comment
|
||||
- [ ] GET /api/ideas/:id/comments
|
||||
@ -92,11 +129,12 @@
|
||||
- [ ] CRUD endpoints
|
||||
- [ ] GET /api/team/summary
|
||||
|
||||
### Frontend — Drag & Drop
|
||||
- [ ] Интегрировать dnd-kit в таблицу
|
||||
- [ ] Drag handle в первой колонке
|
||||
- [ ] Визуальная индикация при перетаскивании
|
||||
- [ ] Сохранение порядка на сервер
|
||||
### Frontend — Drag & Drop ✅
|
||||
- [x] Интегрировать dnd-kit в таблицу
|
||||
- [x] Drag handle в первой колонке
|
||||
- [x] Визуальная индикация при перетаскивании (DragOverlay)
|
||||
- [x] Сохранение порядка на сервер (оптимистичные обновления)
|
||||
- [x] Сортировка по order по умолчанию
|
||||
|
||||
### Frontend — Цветовая маркировка
|
||||
- [ ] Добавить поле color в таблицу
|
||||
@ -119,7 +157,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Фаза 3: AI-интеграция
|
||||
## Фаза 3: AI-интеграция ⏸️
|
||||
|
||||
### Backend — Модуль AI
|
||||
- [ ] Интегрировать ai-proxy service
|
||||
|
||||
@ -7,3 +7,7 @@ DB_DATABASE=teamplanner
|
||||
|
||||
# App
|
||||
PORT=4001
|
||||
|
||||
# Keycloak
|
||||
KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner
|
||||
KEYCLOAK_CLIENT_ID=team-planner-frontend
|
||||
|
||||
@ -30,11 +30,15 @@
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@ -49,6 +53,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { Public } from './auth';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
@Public()
|
||||
health(): { status: string } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { IdeasModule } from './ideas/ideas.module';
|
||||
import { AuthModule, JwtAuthGuard } from './auth';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -26,9 +28,16 @@ import { IdeasModule } from './ideas/ideas.module';
|
||||
synchronize: false,
|
||||
}),
|
||||
}),
|
||||
AuthModule,
|
||||
IdeasModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
11
backend/src/auth/auth.module.ts
Normal file
11
backend/src/auth/auth.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
|
||||
providers: [JwtStrategy, JwtAuthGuard],
|
||||
exports: [JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
4
backend/src/auth/decorators/public.decorator.ts
Normal file
4
backend/src/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
4
backend/src/auth/index.ts
Normal file
4
backend/src/auth/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './auth.module';
|
||||
export * from './jwt-auth.guard';
|
||||
export * from './jwt.strategy';
|
||||
export * from './decorators/public.decorator';
|
||||
24
backend/src/auth/jwt-auth.guard.ts
Normal file
24
backend/src/auth/jwt-auth.guard.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from './decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
51
backend/src/auth/jwt.strategy.ts
Normal file
51
backend/src/auth/jwt.strategy.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { passportJwtSecret } from 'jwks-rsa';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
preferred_username: string;
|
||||
email: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
const realmUrl = configService.get<string>('KEYCLOAK_REALM_URL');
|
||||
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
issuer: realmUrl,
|
||||
algorithms: ['RS256'],
|
||||
secretOrKeyProvider: passportJwtSecret({
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 5,
|
||||
jwksUri: `${realmUrl}/protocol/openid-connect/certs`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): AuthUser {
|
||||
return {
|
||||
userId: payload.sub,
|
||||
username: payload.preferred_username,
|
||||
email: payload.email,
|
||||
firstName: payload.given_name,
|
||||
lastName: payload.family_name,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './create-idea.dto';
|
||||
export * from './update-idea.dto';
|
||||
export * from './query-ideas.dto';
|
||||
export * from './reorder-ideas.dto';
|
||||
|
||||
26
backend/src/ideas/dto/reorder-ideas.dto.ts
Normal file
26
backend/src/ideas/dto/reorder-ideas.dto.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
ArrayMinSize,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ReorderItemDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
order: number;
|
||||
}
|
||||
|
||||
export class ReorderIdeasDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ReorderItemDto)
|
||||
items: ReorderItemDto[];
|
||||
}
|
||||
@ -10,7 +10,12 @@ import {
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { IdeasService } from './ideas.service';
|
||||
import { CreateIdeaDto, UpdateIdeaDto, QueryIdeasDto } from './dto';
|
||||
import {
|
||||
CreateIdeaDto,
|
||||
UpdateIdeaDto,
|
||||
QueryIdeasDto,
|
||||
ReorderIdeasDto,
|
||||
} from './dto';
|
||||
|
||||
@Controller('ideas')
|
||||
export class IdeasController {
|
||||
@ -31,6 +36,11 @@ export class IdeasController {
|
||||
return this.ideasService.getModules();
|
||||
}
|
||||
|
||||
@Patch('reorder')
|
||||
reorder(@Body() reorderIdeasDto: ReorderIdeasDto) {
|
||||
return this.ideasService.reorder(reorderIdeasDto.items);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.ideasService.findOne(id);
|
||||
|
||||
@ -123,4 +123,12 @@ export class IdeasService {
|
||||
|
||||
return result.map((r) => r.module).filter(Boolean);
|
||||
}
|
||||
|
||||
async reorder(items: { id: string; order: number }[]): Promise<void> {
|
||||
await this.ideasRepository.manager.transaction(async (manager) => {
|
||||
for (const item of items) {
|
||||
await manager.update(Idea, item.id, { order: item.order });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
4
frontend/.env.example
Normal file
4
frontend/.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
# Keycloak
|
||||
VITE_KEYCLOAK_URL=https://auth.vigdorov.ru
|
||||
VITE_KEYCLOAK_REALM=team-planner
|
||||
VITE_KEYCLOAK_CLIENT_ID=team-planner-frontend
|
||||
@ -12,10 +12,16 @@ RUN npm install
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build argument for API URL (optional, defaults to empty for production)
|
||||
# Empty value means use relative paths, which works with nginx proxy
|
||||
# Build arguments
|
||||
ARG VITE_API_URL=""
|
||||
ARG VITE_KEYCLOAK_URL="https://auth.vigdorov.ru"
|
||||
ARG VITE_KEYCLOAK_REALM="team-planner"
|
||||
ARG VITE_KEYCLOAK_CLIENT_ID="team-planner-frontend"
|
||||
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL
|
||||
ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM
|
||||
ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
@ -22,6 +23,7 @@
|
||||
"@tanstack/react-query": "^5.90.14",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.2",
|
||||
"keycloak-js": "^26.2.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zustand": "^5.0.9"
|
||||
|
||||
93
frontend/src/components/AuthProvider/AuthProvider.tsx
Normal file
93
frontend/src/components/AuthProvider/AuthProvider.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import keycloak from '../../services/keycloak';
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const didInit = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Предотвращаем двойную инициализацию в StrictMode
|
||||
if (didInit.current) {
|
||||
return;
|
||||
}
|
||||
didInit.current = true;
|
||||
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const initKeycloak = async () => {
|
||||
try {
|
||||
const authenticated = await keycloak.init({
|
||||
onLoad: 'login-required',
|
||||
checkLoginIframe: false,
|
||||
pkceMethod: 'S256',
|
||||
});
|
||||
|
||||
setIsAuthenticated(authenticated);
|
||||
|
||||
if (authenticated) {
|
||||
// Автоматическое обновление токена
|
||||
refreshInterval = setInterval(() => {
|
||||
keycloak.updateToken(30).catch(() => {
|
||||
console.error('Failed to refresh token');
|
||||
void keycloak.login();
|
||||
});
|
||||
}, 10000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Keycloak init failed:', error);
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void initKeycloak();
|
||||
|
||||
return () => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
<Typography>Авторизация...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Typography>Ошибка авторизации. Перенаправление...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
1
frontend/src/components/AuthProvider/index.ts
Normal file
1
frontend/src/components/AuthProvider/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AuthProvider } from './AuthProvider';
|
||||
91
frontend/src/components/IdeasTable/DraggableRow.tsx
Normal file
91
frontend/src/components/IdeasTable/DraggableRow.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { TableRow, TableCell, Box } from '@mui/material';
|
||||
import { DragIndicator } from '@mui/icons-material';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import type { Idea } from '../../types/idea';
|
||||
|
||||
// Контекст для передачи информации о drag handle в ячейку
|
||||
interface DragHandleContextValue {
|
||||
attributes: ReturnType<typeof useSortable>['attributes'];
|
||||
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
const DragHandleContext = createContext<DragHandleContextValue | null>(null);
|
||||
|
||||
// Компонент drag handle для использования в колонке
|
||||
export function DragHandle() {
|
||||
const context = useContext(DragHandleContext);
|
||||
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { attributes, listeners, isDragging } = context;
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
sx={{
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'text.secondary',
|
||||
touchAction: 'none',
|
||||
'&:hover': { color: 'text.primary' },
|
||||
}}
|
||||
>
|
||||
<DragIndicator fontSize="small" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface DraggableRowProps {
|
||||
row: Row<Idea>;
|
||||
}
|
||||
|
||||
export function DraggableRow({ row }: DraggableRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: row.original.id });
|
||||
|
||||
// Используем CSS.Translate вместо CSS.Transform для лучшей совместимости с таблицами
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
backgroundColor: row.original.color
|
||||
? `${row.original.color}15`
|
||||
: isDragging
|
||||
? 'action.hover'
|
||||
: undefined,
|
||||
position: isDragging ? ('relative' as const) : undefined,
|
||||
zIndex: isDragging ? 1000 : undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: row.original.color
|
||||
? `${row.original.color}25`
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<DragHandleContext.Provider value={{ attributes, listeners, isDragging }}>
|
||||
<TableRow ref={setNodeRef} hover sx={style}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</DragHandleContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,25 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -19,18 +35,27 @@ import {
|
||||
TablePagination,
|
||||
} from '@mui/material';
|
||||
import { Inbox } from '@mui/icons-material';
|
||||
import { useIdeasQuery, useDeleteIdea } from '../../hooks/useIdeas';
|
||||
import {
|
||||
useIdeasQuery,
|
||||
useDeleteIdea,
|
||||
useReorderIdeas,
|
||||
} from '../../hooks/useIdeas';
|
||||
import { useIdeasStore } from '../../store/ideas';
|
||||
import { createColumns } from './columns';
|
||||
import { DraggableRow } from './DraggableRow';
|
||||
|
||||
const SKELETON_COLUMNS_COUNT = 7;
|
||||
const SKELETON_COLUMNS_COUNT = 8;
|
||||
|
||||
export function IdeasTable() {
|
||||
const { data, isLoading, isError } = useIdeasQuery();
|
||||
const deleteIdea = useDeleteIdea();
|
||||
const reorderIdeas = useReorderIdeas();
|
||||
const { sorting, setSorting, pagination, setPage, setLimit } =
|
||||
useIdeasStore();
|
||||
|
||||
// ID активно перетаскиваемого элемента
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const columns = useMemo(
|
||||
() => createColumns((id) => deleteIdea.mutate(id)),
|
||||
[deleteIdea],
|
||||
@ -43,8 +68,49 @@ export function IdeasTable() {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
manualPagination: true,
|
||||
getRowId: (row) => row.id,
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (over && active.id !== over.id && data?.data) {
|
||||
const oldIndex = data.data.findIndex((item) => item.id === active.id);
|
||||
const newIndex = data.data.findIndex((item) => item.id === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
// Создаём новый порядок
|
||||
const items = [...data.data];
|
||||
const [movedItem] = items.splice(oldIndex, 1);
|
||||
items.splice(newIndex, 0, movedItem);
|
||||
|
||||
// Отправляем на сервер новый порядок
|
||||
const reorderItems = items.map((item, index) => ({
|
||||
id: item.id,
|
||||
order: index,
|
||||
}));
|
||||
|
||||
reorderIdeas.mutate(reorderItems);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (columnId: string) => {
|
||||
setSorting(columnId);
|
||||
};
|
||||
@ -67,8 +133,21 @@ export function IdeasTable() {
|
||||
);
|
||||
}
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
const rowIds = rows.map((row) => row.original.id);
|
||||
const activeRow = activeId
|
||||
? rows.find((row) => row.original.id === activeId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<TableContainer>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
@ -124,7 +203,7 @@ export function IdeasTable() {
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={SKELETON_COLUMNS_COUNT}>
|
||||
<Box
|
||||
@ -145,23 +224,40 @@ export function IdeasTable() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
hover
|
||||
<SortableContext
|
||||
items={rowIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<DraggableRow key={row.id} row={row} />
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeRow ? (
|
||||
<Table
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: row.original.color
|
||||
? `${row.original.color}15`
|
||||
: undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: row.original.color
|
||||
? `${row.original.color}25`
|
||||
: undefined,
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 6,
|
||||
borderRadius: 1,
|
||||
'& td': {
|
||||
backgroundColor: activeRow.original.color
|
||||
? `${activeRow.original.color}30`
|
||||
: 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
{activeRow.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
sx={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
@ -169,11 +265,11 @@ export function IdeasTable() {
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{data && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
|
||||
@ -4,6 +4,7 @@ import { Delete } from '@mui/icons-material';
|
||||
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
|
||||
import { EditableCell } from './EditableCell';
|
||||
import { statusOptions, priorityOptions } from './constants';
|
||||
import { DragHandle } from './DraggableRow';
|
||||
|
||||
const columnHelper = createColumnHelper<Idea>();
|
||||
|
||||
@ -29,6 +30,13 @@ const priorityColors: Record<
|
||||
};
|
||||
|
||||
export const createColumns = (onDelete: (id: string) => void) => [
|
||||
columnHelper.display({
|
||||
id: 'drag',
|
||||
header: '',
|
||||
cell: () => <DragHandle />,
|
||||
size: 40,
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.accessor('title', {
|
||||
header: 'Название',
|
||||
cell: (info) => (
|
||||
|
||||
@ -69,3 +69,57 @@ export function useDeleteIdea() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderIdeas() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (items: { id: string; order: number }[]) =>
|
||||
ideasApi.reorder(items),
|
||||
onMutate: async (items) => {
|
||||
// Получаем актуальное состояние store
|
||||
const { filters, sorting, pagination } = useIdeasStore.getState();
|
||||
|
||||
// Отменяем исходящие запросы чтобы не перезаписать оптимистичное обновление
|
||||
await queryClient.cancelQueries({ queryKey: [QUERY_KEY] });
|
||||
|
||||
// Сохраняем предыдущее состояние для отката
|
||||
const queryKey = [QUERY_KEY, { ...filters, ...sorting, ...pagination }];
|
||||
const previousData = queryClient.getQueryData(queryKey);
|
||||
|
||||
// Оптимистично обновляем кэш
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
(
|
||||
old:
|
||||
| { data: { id: string; order: number }[]; meta: unknown }
|
||||
| undefined,
|
||||
) => {
|
||||
if (!old) return old;
|
||||
|
||||
// Создаём новый порядок на основе items
|
||||
const orderMap = new Map(items.map((item) => [item.id, item.order]));
|
||||
const newData = [...old.data].sort((a, b) => {
|
||||
const orderA = orderMap.get(a.id) ?? a.order;
|
||||
const orderB = orderMap.get(b.id) ?? b.order;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return { ...old, data: newData };
|
||||
},
|
||||
);
|
||||
|
||||
return { previousData, queryKey };
|
||||
},
|
||||
onError: (_err, _items, context) => {
|
||||
// Откатываем к предыдущему состоянию при ошибке
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(context.queryKey, context.previousData);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// Инвалидируем для синхронизации с сервером
|
||||
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { AuthProvider } from './components/AuthProvider';
|
||||
import App from './App.tsx';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@ -30,7 +31,9 @@ createRoot(rootElement).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import axios, { type AxiosError } from 'axios';
|
||||
import keycloak from './keycloak';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@ -6,3 +7,33 @@ export const api = axios.create({
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor для добавления Authorization header
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
if (keycloak.token) {
|
||||
try {
|
||||
await keycloak.updateToken(5);
|
||||
} catch {
|
||||
void keycloak.login();
|
||||
return Promise.reject(new Error('Token refresh failed'));
|
||||
}
|
||||
|
||||
config.headers.Authorization = `Bearer ${keycloak.token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error: unknown) =>
|
||||
Promise.reject(error instanceof Error ? error : new Error('Request error')),
|
||||
);
|
||||
|
||||
// Interceptor для обработки 401 ошибок
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
void keycloak.login();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@ -61,4 +61,8 @@ export const ideasApi = {
|
||||
const { data } = await api.get<string[]>('/ideas/modules');
|
||||
return data;
|
||||
},
|
||||
|
||||
reorder: async (items: { id: string; order: number }[]): Promise<void> => {
|
||||
await api.patch('/ideas/reorder', { items });
|
||||
},
|
||||
};
|
||||
|
||||
9
frontend/src/services/keycloak.ts
Normal file
9
frontend/src/services/keycloak.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import Keycloak from 'keycloak-js';
|
||||
|
||||
const keycloak = new Keycloak({
|
||||
url: import.meta.env.VITE_KEYCLOAK_URL as string,
|
||||
realm: import.meta.env.VITE_KEYCLOAK_REALM as string,
|
||||
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID as string,
|
||||
});
|
||||
|
||||
export default keycloak;
|
||||
@ -42,7 +42,7 @@ interface IdeasStore {
|
||||
}
|
||||
|
||||
const initialFilters: IdeasFilters = {};
|
||||
const initialSorting: IdeasSorting = { sortBy: 'createdAt', sortOrder: 'DESC' };
|
||||
const initialSorting: IdeasSorting = { sortBy: 'order', sortOrder: 'ASC' };
|
||||
const initialPagination: IdeasPagination = { page: 1, limit: 20 };
|
||||
|
||||
export const useIdeasStore = create<IdeasStore>((set) => ({
|
||||
|
||||
641
package-lock.json
generated
641
package-lock.json
generated
@ -27,11 +27,15 @@
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@ -46,6 +50,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
@ -2302,23 +2307,6 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"dev": true,
|
||||
@ -2368,11 +2356,6 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"backend/node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"dev": true,
|
||||
@ -2421,24 +2404,6 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"backend/node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"backend/node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/@types/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"dev": true,
|
||||
@ -6829,24 +6794,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"backend/node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"license": "MIT"
|
||||
@ -6868,17 +6815,6 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"license": "MIT",
|
||||
@ -8465,6 +8401,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
@ -8474,6 +8411,7 @@
|
||||
"@tanstack/react-query": "^5.90.14",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.2",
|
||||
"keycloak-js": "^26.2.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zustand": "^5.0.9"
|
||||
@ -8735,29 +8673,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"license": "MIT",
|
||||
@ -8770,16 +8685,6 @@
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"license": "MIT",
|
||||
@ -10975,23 +10880,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/react": {
|
||||
"version": "19.2.3",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/react-is": {
|
||||
"version": "19.2.3",
|
||||
"license": "MIT"
|
||||
@ -11083,10 +10971,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"frontend/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"dev": true,
|
||||
@ -11225,11 +11109,6 @@
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"frontend/node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"frontend/node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"dev": true,
|
||||
@ -11450,6 +11329,59 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/modifiers": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/csprng": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
|
||||
@ -11510,6 +11442,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/passport": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||
"integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
@ -11533,6 +11475,160 @@
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "4.17.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
"@types/qs": "*",
|
||||
"@types/serve-static": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "4.19.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.7.tgz",
|
||||
"integrity": "sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "*",
|
||||
"@types/passport-strategy": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-strategy": {
|
||||
"version": "0.2.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
"@types/send": "<1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static/node_modules/@types/send": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/validator": {
|
||||
"version": "13.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||
@ -11567,6 +11663,12 @@
|
||||
"resolved": "backend",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@ -11688,6 +11790,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@ -11782,12 +11893,95 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jwks-rsa": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
|
||||
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/jsonwebtoken": "^9.0.4",
|
||||
"debug": "^4.3.4",
|
||||
"jose": "^4.15.4",
|
||||
"limiter": "^1.1.5",
|
||||
"lru-memoizer": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keycloak-js": {
|
||||
"version": "26.2.2",
|
||||
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.2.tgz",
|
||||
"integrity": "sha512-ug7pNZ1xNkd7PPkerOJCEU2VnUhS7CYStDOCFJgqCNQ64h53ppxaKrh4iXH0xM8hFu5b1W6e6lsyYWqBMvaQFg==",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"test"
|
||||
]
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.12.33",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz",
|
||||
"integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/limiter": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
||||
},
|
||||
"node_modules/load-esm": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz",
|
||||
@ -11807,12 +12001,123 @@
|
||||
"node": ">=13.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-memoizer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
|
||||
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/passport": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
"utils-merge": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pause": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
||||
@ -11829,6 +12134,27 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
@ -11853,6 +12179,44 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
@ -11982,6 +12346,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.26",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
|
||||
@ -12017,6 +12396,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
|
||||
25
tests/e2e/auth.setup.ts
Normal file
25
tests/e2e/auth.setup.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'playwright/.auth/user.json';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
// Переходим на главную - редирект на Keycloak
|
||||
await page.goto('/');
|
||||
|
||||
// Ждём страницу логина Keycloak
|
||||
await page.waitForURL(/auth\.vigdorov\.ru/, { timeout: 10000 });
|
||||
|
||||
// Вводим креды
|
||||
await page.getByRole('textbox', { name: 'Username or email' }).fill('testuser');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('0');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Ждём редирект обратно на приложение
|
||||
await page.waitForURL('http://localhost:4000/', { timeout: 15000 });
|
||||
|
||||
// Ждём загрузки таблицы (значит авторизация прошла)
|
||||
await page.waitForSelector('table', { timeout: 10000 });
|
||||
|
||||
// Сохраняем состояние авторизации
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
155
tests/e2e/phase1.spec.ts
Normal file
155
tests/e2e/phase1.spec.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E тесты для Фазы 1 Team Planner
|
||||
* - Базовая загрузка страницы
|
||||
* - Таблица идей
|
||||
* - Фильтры
|
||||
* - Создание идей
|
||||
* - Inline-редактирование
|
||||
* - Удаление
|
||||
*/
|
||||
|
||||
test.describe('Фаза 1: Базовый функционал', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Ждём загрузки таблицы
|
||||
await page.waitForSelector('table, [role="grid"]', { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('Страница загружается', async ({ page }) => {
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
await expect(page).toHaveTitle(/.*/);
|
||||
});
|
||||
|
||||
test('Таблица идей отображается', async ({ page }) => {
|
||||
const table = page.locator('table, [role="grid"]');
|
||||
await expect(table).toBeVisible();
|
||||
});
|
||||
|
||||
test('Таблица имеет заголовки колонок', async ({ page }) => {
|
||||
const headers = page.locator('th, [role="columnheader"]');
|
||||
const count = await headers.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
// Проверяем что есть хотя бы несколько важных колонок
|
||||
const headerTexts = await headers.allTextContents();
|
||||
expect(headerTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Фильтры присутствуют на странице', async ({ page }) => {
|
||||
// Ищем элементы фильтров (inputs, selects, MUI компоненты)
|
||||
const filterElements = page.locator('input, [role="combobox"], .MuiSelect-select');
|
||||
const count = await filterElements.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('Поле поиска работает', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Поиск"]');
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await searchInput.fill('test');
|
||||
await expect(searchInput).toHaveValue('test');
|
||||
|
||||
// Очищаем
|
||||
await searchInput.clear();
|
||||
});
|
||||
|
||||
test('Кнопка создания идеи существует', async ({ page }) => {
|
||||
const buttons = page.locator('button');
|
||||
const createButton = buttons.filter({
|
||||
hasText: /создать|добавить|новая|\+/i,
|
||||
});
|
||||
|
||||
await expect(createButton.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Модалка создания открывается', async ({ page }) => {
|
||||
// Находим и кликаем кнопку создания
|
||||
const createButton = page
|
||||
.locator('button')
|
||||
.filter({ hasText: /создать|добавить|новая/i })
|
||||
.first();
|
||||
|
||||
await createButton.click();
|
||||
|
||||
// Проверяем что модалка открылась (используем .first() т.к. MUI создаёт вложенные элементы)
|
||||
const modal = page.locator('[role="dialog"]').first();
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Закрываем модалку
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(modal).toBeHidden();
|
||||
});
|
||||
|
||||
test('Таблица показывает данные или empty state', async ({ page }) => {
|
||||
const rows = page.locator('tbody tr, [role="row"]');
|
||||
const rowCount = await rows.count();
|
||||
|
||||
if (rowCount > 1) {
|
||||
// Есть данные
|
||||
expect(rowCount).toBeGreaterThan(1);
|
||||
} else {
|
||||
// Ищем empty state
|
||||
const emptyState = page.locator('text=/нет|пусто|Нет идей/i');
|
||||
const hasEmptyState = (await emptyState.count()) > 0;
|
||||
expect(hasEmptyState || rowCount >= 1).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('Пагинация присутствует', async ({ page }) => {
|
||||
const pagination = page.locator(
|
||||
'.MuiTablePagination-root, [aria-label*="pagination"], nav[aria-label*="pagination"]'
|
||||
);
|
||||
await expect(pagination.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Inline-редактирование работает (double-click)', async ({ page }) => {
|
||||
// Находим ячейки таблицы (пропускаем первую - drag handle)
|
||||
const cells = page.locator('tbody td');
|
||||
const cellCount = await cells.count();
|
||||
|
||||
if (cellCount > 1) {
|
||||
// Пробуем double-click на ячейках (начиная со второй)
|
||||
for (let i = 1; i < Math.min(cellCount, 6); i++) {
|
||||
const cell = cells.nth(i);
|
||||
const text = await cell.textContent();
|
||||
|
||||
if (text && text.trim()) {
|
||||
await cell.dblclick();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Проверяем появился ли input для редактирования
|
||||
const input = page.locator(
|
||||
'.MuiInputBase-input, input.MuiInput-input, tbody input, [role="combobox"]'
|
||||
);
|
||||
const inputCount = await input.count();
|
||||
|
||||
if (inputCount > 0) {
|
||||
// Отменяем редактирование
|
||||
await page.keyboard.press('Escape');
|
||||
return; // Тест прошёл
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет данных для inline-редактирования - это ОК
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Кнопка удаления в таблице', async ({ page }) => {
|
||||
// Ищем иконки/кнопки удаления в строках
|
||||
const deleteButtons = page.locator(
|
||||
'tbody button[aria-label*="delete"], tbody button[aria-label*="удалить"], tbody [data-testid="DeleteIcon"], tbody svg'
|
||||
);
|
||||
|
||||
const count = await deleteButtons.count();
|
||||
// Если есть данные, должны быть кнопки удаления
|
||||
const rows = await page.locator('tbody tr').count();
|
||||
|
||||
if (rows > 0) {
|
||||
expect(count).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
208
tests/e2e/phase2.spec.ts
Normal file
208
tests/e2e/phase2.spec.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E тесты для Фазы 2 Team Planner
|
||||
* - Drag & Drop
|
||||
* - Цветовая маркировка
|
||||
* - Комментарии
|
||||
* - Управление командой
|
||||
*/
|
||||
|
||||
test.describe('Фаза 2: Drag & Drop', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('table, [role="grid"]', { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('Drag handle присутствует в таблице', async ({ page }) => {
|
||||
// Ждём загрузки строк таблицы
|
||||
await page.waitForSelector('tbody tr', { timeout: 10000 });
|
||||
|
||||
// Drag handle — это div с aria-roledescription="sortable" (dnd-kit)
|
||||
const handles = page.locator('[aria-roledescription="sortable"]');
|
||||
|
||||
// Ждём появления хотя бы одного handle
|
||||
await expect(handles.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const count = await handles.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Строки имеют drag handle для сортировки', async ({ page }) => {
|
||||
// Ждём загрузки строк таблицы
|
||||
await page.waitForSelector('tbody tr', { timeout: 10000 });
|
||||
|
||||
// dnd-kit добавляет aria-roledescription="sortable" на drag handle
|
||||
const handles = page.locator('[aria-roledescription="sortable"]');
|
||||
await expect(handles.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const count = await handles.count();
|
||||
const totalRows = await page.locator('tbody tr').count();
|
||||
|
||||
// Все строки должны иметь drag handle
|
||||
expect(count).toBe(totalRows);
|
||||
});
|
||||
|
||||
test('Визуальное перетаскивание работает', async ({ page }) => {
|
||||
const rows = page.locator('tbody tr');
|
||||
const rowCount = await rows.count();
|
||||
|
||||
if (rowCount >= 2) {
|
||||
const firstRow = rows.first();
|
||||
const handle = firstRow.locator('td:first-child svg, td:first-child').first();
|
||||
|
||||
// Начинаем перетаскивание
|
||||
const box = await handle.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2 + 50);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Проверяем появление визуальной индикации
|
||||
const overlay = page.locator(
|
||||
'[data-dnd-kit-drag-overlay], ' + '.drag-overlay, ' + '[style*="position: fixed"]'
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
// Drag action выполнен успешно
|
||||
expect(true).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Фаза 2: Цветовая маркировка', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('table, [role="grid"]', { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('Колонка цвета присутствует', async ({ page }) => {
|
||||
const headers = page.locator('th, [role="columnheader"]');
|
||||
const headerTexts = await headers.allTextContents();
|
||||
|
||||
const hasColorColumn = headerTexts.some(
|
||||
(text) => text.toLowerCase().includes('цвет') || text.toLowerCase().includes('color')
|
||||
);
|
||||
|
||||
// Фича может быть ещё не реализована - отмечаем как skip
|
||||
test.skip(!hasColorColumn, 'Колонка цвета ещё не реализована');
|
||||
expect(hasColorColumn).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Color picker или индикаторы доступны', async ({ page }) => {
|
||||
const colorElements = page.locator(
|
||||
'input[type="color"], ' +
|
||||
'.color-picker, ' +
|
||||
'[aria-label*="цвет" i], ' +
|
||||
'[aria-label*="color" i], ' +
|
||||
'tbody [style*="background"]'
|
||||
);
|
||||
|
||||
const count = await colorElements.count();
|
||||
// Фича может быть ещё не реализована
|
||||
test.skip(count === 0, 'Color picker ещё не реализован');
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Строки могут иметь цветной фон', async ({ page }) => {
|
||||
const rows = page.locator('tbody tr');
|
||||
const rowCount = await rows.count();
|
||||
let coloredRows = 0;
|
||||
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const row = rows.nth(i);
|
||||
const bg = await row.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
|
||||
// Проверяем что фон не прозрачный и не белый
|
||||
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'rgb(255, 255, 255)') {
|
||||
coloredRows++;
|
||||
}
|
||||
}
|
||||
|
||||
// Фича может быть ещё не реализована
|
||||
test.skip(coloredRows === 0, 'Цветные строки ещё не реализованы');
|
||||
expect(coloredRows).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Фаза 2: Комментарии', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('table, [role="grid"]', { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('Кнопка комментариев присутствует', async ({ page }) => {
|
||||
const commentButtons = page.locator(
|
||||
'[aria-label*="комментар" i], ' +
|
||||
'[aria-label*="comment" i], ' +
|
||||
'button svg[data-testid*="Comment"], ' +
|
||||
'[data-testid="CommentIcon"], ' +
|
||||
'[data-testid="ChatBubbleIcon"]'
|
||||
);
|
||||
|
||||
const count = await commentButtons.count();
|
||||
// Фича может быть ещё не реализована
|
||||
test.skip(count === 0, 'Комментарии ещё не реализованы');
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Секция комментариев существует', async ({ page }) => {
|
||||
const commentsSection = page.locator(
|
||||
'.comments-section, ' +
|
||||
'[class*="comment"], ' +
|
||||
'[data-testid*="comment"], ' +
|
||||
'textarea[placeholder*="комментар" i]'
|
||||
);
|
||||
|
||||
const count = await commentsSection.count();
|
||||
// Фича может быть ещё не реализована
|
||||
test.skip(count === 0, 'Секция комментариев ещё не реализована');
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Фаза 2: Управление командой', () => {
|
||||
test('Страница /team существует', async ({ page }) => {
|
||||
await page.goto('/team');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
const is404 =
|
||||
bodyText?.toLowerCase().includes('404') || bodyText?.toLowerCase().includes('not found');
|
||||
|
||||
// Фича может быть ещё не реализована
|
||||
test.skip(is404, 'Страница /team ещё не реализована');
|
||||
expect(is404).toBeFalsy();
|
||||
});
|
||||
|
||||
test('Ссылка на команду в навигации', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('body');
|
||||
|
||||
const teamLinks = page.locator('a[href*="team"], nav a, [role="navigation"] a');
|
||||
const allLinks = await teamLinks.allTextContents();
|
||||
|
||||
const hasTeamLink = allLinks.some(
|
||||
(text) => text.toLowerCase().includes('команд') || text.toLowerCase().includes('team')
|
||||
);
|
||||
|
||||
// Фича может быть ещё не реализована
|
||||
test.skip(!hasTeamLink, 'Навигация на команду ещё не реализована');
|
||||
expect(hasTeamLink).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Таблица участников команды', async ({ page }) => {
|
||||
await page.goto('/team');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const table = page.locator('table, [role="grid"]');
|
||||
const count = await table.count();
|
||||
|
||||
// Фича может быть ещё не реализована
|
||||
test.skip(count === 0, 'Таблица команды ещё не реализована');
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
78
tests/package-lock.json
generated
Normal file
78
tests/package-lock.json
generated
Normal file
@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "team-planner-e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "team-planner-e2e",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
tests/package.json
Normal file
16
tests/package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "team-planner-e2e",
|
||||
"version": "1.0.0",
|
||||
"description": "E2E тесты для Team Planner",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:phase1": "playwright test phase1.spec.ts",
|
||||
"test:phase2": "playwright test phase2.spec.ts",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0"
|
||||
}
|
||||
}
|
||||
34
tests/playwright.config.ts
Normal file
34
tests/playwright.config.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:4000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
// Setup project - авторизация
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
|
||||
// Основные тесты - используют сохранённую авторизацию
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
45
tests/playwright/.auth/user.json
Normal file
45
tests/playwright/.auth/user.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "AUTH_SESSION_ID",
|
||||
"value": "MkdCeDFEb21wOHQ5Q19seWs2ZlNZaGVELkZyajNfUDlQMGZlMWlqWUVVNkxQTXZ5NEQ3U2NaLW8zeXR3Tk1nTjNLdTVtcVZTY3JxWnduV01Cc0xodmJnLVd2a1E4SHVJbWJWcDlieEdOU1dYSm5B.keycloak-keycloakx-0-27122",
|
||||
"domain": "auth.vigdorov.ru",
|
||||
"path": "/realms/team-planner/",
|
||||
"expires": -1,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "KC_AUTH_SESSION_HASH",
|
||||
"value": "\"pLzIGfYFD8RX7GW+uEm+YT/ECPbJUQyFtcksML49rHY\"",
|
||||
"domain": "auth.vigdorov.ru",
|
||||
"path": "/realms/team-planner/",
|
||||
"expires": 1768340584.145523,
|
||||
"httpOnly": false,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "KEYCLOAK_IDENTITY",
|
||||
"value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3NjgzNzY1MjcsImlhdCI6MTc2ODM0MDUyNywianRpIjoiODAyMWRlMzQtYWIzMy0wOGE1LTEwMGUtMzcyNDZiNTQwZTRmIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoiMkdCeDFEb21wOHQ5Q19seWs2ZlNZaGVEIiwic3RhdGVfY2hlY2tlciI6Im1KMW5ReHlVNVBvdUlPV0NXc1otMWlzYmpfejAxa21qTWt2U2xTVEx6RVkifQ.GBtVMik4s9okAtfiDRj-E12VoQL4RKb11QVO8zSXCMguz0Hmu4gL3n8BgZLS4nkhqIUmPGbijdNgrPaoyebyMQ",
|
||||
"domain": "auth.vigdorov.ru",
|
||||
"path": "/realms/team-planner/",
|
||||
"expires": -1,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "KEYCLOAK_SESSION",
|
||||
"value": "pLzIGfYFD8RX7GW-uEm-YT_ECPbJUQyFtcksML49rHY",
|
||||
"domain": "auth.vigdorov.ru",
|
||||
"path": "/realms/team-planner/",
|
||||
"expires": 1768376527.37812,
|
||||
"httpOnly": false,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
}
|
||||
],
|
||||
"origins": []
|
||||
}
|
||||
4
tests/test-results/.last-run.json
Normal file
4
tests/test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Reference in New Issue
Block a user