From fc5801d683385cbf5f4a3e38e7833e61e6716cc5 Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 2 Oct 2026 04:07:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=B0=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- .../app/config/auth/JwtTokenService.java | 6 +- .../app/config/auth/JwtTokenServiceTest.java | 36 +++ docs/ARCHITECTURE.md | 3 + docs/FRONTEND.md | 69 ++++- docs/README.md | 2 +- docs/UI_COMPONENTS.md | 64 +++-- frontend/admin/css/components.css | 81 ++++-- frontend/admin/css/layout.css | 30 ++- frontend/admin/css/main.css | 8 +- frontend/admin/settings/css/layout.css | 34 ++- frontend/admin/settings/css/main.css | 12 +- frontend/package-lock.json | 48 ++++ frontend/package.json | 4 +- frontend/react/admin/AdminApp.jsx | 41 +-- .../react/admin/tabs/AcademicCalendarTab.jsx | 45 ++-- .../admin/tabs/AuditoriumWorkloadTab.jsx | 9 +- .../admin/tabs/DepartmentWorkspaceTab.jsx | 9 +- frontend/react/admin/tabs/GroupsTab.jsx | 48 ++-- .../react/admin/tabs/ScheduleQualityTab.jsx | 21 +- frontend/react/admin/tabs/ScheduleTab.jsx | 73 ++--- .../react/admin/tabs/ScheduleVersionsTab.jsx | 9 +- frontend/react/admin/tabs/ScheduleViewTab.jsx | 37 +-- frontend/react/admin/tabs/SubjectsTab.jsx | 15 +- .../react/admin/tabs/TeacherAbsencesTab.jsx | 21 +- .../react/admin/tabs/TeacherRequestsTab.jsx | 11 +- .../admin/tabs/UniversityStructureTab.jsx | 5 +- frontend/react/admin/tabs/UsersTab.jsx | 15 +- .../react/admin/teacher-absences-service.js | 3 +- frontend/react/settings/SettingsApp.jsx | 26 +- frontend/react/shared/ui/AsyncCombobox.jsx | 32 ++- frontend/react/shared/ui/CustomSelect.jsx | 171 ++++++++++++ frontend/react/shared/ui/Pagination.jsx | 5 +- frontend/react/shared/ui/SidebarToggle.jsx | 16 ++ frontend/react/shared/use-sidebar.js | 40 +++ frontend/scripts/build-react.mjs | 11 +- frontend/style.css | 2 - frontend/teacher/style.css | 5 +- .../tests/browser/shared-controls.test.mjs | 152 +++++++++++ .../tests/browser/ui-regressions.test.mjs | 254 ++++++++++++++++++ frontend/tests/teacher-absences.test.mjs | 15 ++ frontend/ui-foundation.css | 14 +- 42 files changed, 1201 insertions(+), 305 deletions(-) create mode 100644 frontend/react/shared/ui/CustomSelect.jsx create mode 100644 frontend/react/shared/ui/SidebarToggle.jsx create mode 100644 frontend/react/shared/use-sidebar.js create mode 100644 frontend/tests/browser/shared-controls.test.mjs create mode 100644 frontend/tests/browser/ui-regressions.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 0519e4e..240b688 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Проект представляет собой систему управления университетским расписанием. - **Backend**: Java 17, Spring Boot 3.5.16 (Мультитенантная архитектура: отдельная БД для каждого клиента) -- **Frontend**: поэтапный переход с Vanilla JavaScript на React 19 + HTML/CSS +- **Frontend**: React 19 + HTML/CSS (все пользовательские страницы и вкладки) - **Database**: PostgreSQL (множество БД, управляются через Flyway) - **Локальный URL**: localhost:80 - **Продакшн URL**: https://magistr.zuev.company @@ -25,7 +25,7 @@ magistr/ │ ├── utils/ # Утилиты │ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД) ├── frontend/ # Статические файлы -│ ├── react/ # React entrypoint и компоненты уже перенесённых страниц +│ ├── react/ # React entrypoint, страницы и общие компоненты │ ├── admin/ # Интерфейс администратора │ │ └── settings/ # Страница настроек (отдельный SPA) │ ├── department/ # Redirect в кабинет кафедры внутри admin SPA diff --git a/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java b/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java index 49326af..a438c35 100644 --- a/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java +++ b/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java @@ -85,7 +85,11 @@ public class JwtTokenService { Long userId = asLong(jwt.getClaim("userId")).orElseGet(() -> Long.valueOf(jwt.getSubject())); String username = jwt.getClaimAsString("username"); - Role role = Role.valueOf(jwt.getClaimAsString("role")); + String roleClaim = jwt.getClaimAsString("role"); + if (userId <= 0 || username == null || username.isBlank() || roleClaim == null) { + return Optional.empty(); + } + Role role = Role.valueOf(roleClaim); Long departmentId = asLong(jwt.getClaim("departmentId")).orElse(null); return Optional.of(new AuthenticatedUser(userId, username, role, departmentId)); diff --git a/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java b/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java index e233672..06f9e73 100644 --- a/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java +++ b/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java @@ -59,6 +59,42 @@ class JwtTokenServiceTest { assertThat(service.authenticate(token, "magistr")).isEmpty(); } + @Test + void rejectsTokenWithMissingRole() throws JOSEException { + String secret = "12345678901234567890123456789012"; + JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15))); + assertThat(service.authenticate(signedToken(secret, null, "admin", 10L), "magistr")).isEmpty(); + } + + @Test + void rejectsTokenWithMissingUsername() throws JOSEException { + String secret = "12345678901234567890123456789012"; + JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15))); + assertThat(service.authenticate(signedToken(secret, "ADMIN", null, 10L), "magistr")).isEmpty(); + } + + @Test + void rejectsTokenWithInvalidUserId() throws JOSEException { + String secret = "12345678901234567890123456789012"; + JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15))); + assertThat(service.authenticate(signedToken(secret, "ADMIN", "admin", -1L), "magistr")).isEmpty(); + } + + private String signedToken(String secret, String role, String username, long userId) throws JOSEException { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("magistr") + .subject(Long.toString(userId)) + .expirationTime(Date.from(Instant.now().plusSeconds(900))) + .claim("tenant", "magistr") + .claim("userId", userId) + .claim("username", username) + .claim("role", role) + .build(); + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(secret.getBytes(StandardCharsets.UTF_8))); + return jwt.serialize(); + } + private String expiredToken(String secret) { try { Instant now = Instant.now(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce33eda..4afa278 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -425,6 +425,9 @@ Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `de frontend восстанавливает access JWT и профиль в памяти через refresh-cookie; Web Storage для данных авторизации не используется. Backend возвращает `401`, если токен отсутствует или некорректен, и `403`, если роли недостаточно. +Подписанный токен с отсутствующей ролью или пустым логином, а также с неположительным +ID пользователя отклоняется как некорректный. Отсутствие обязательных данных профиля +не должно приводить к внутренней ошибке сервера. `AuthContext` существует только в границах одного servlet-запроса. Интерцептор очищает `ThreadLocal` до любых ранних выходов, устанавливает пользователя только после успешной diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 8f94f6c..218d926 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -4,15 +4,15 @@ | Параметр | Значение | |----------|----------| -| **Фреймворк** | Переходный режим: React 19 для страницы входа, кабинетов студента и преподавателя, страницы настроек и общего слоя `react/shared`, Vanilla JavaScript для остальных разделов | +| **Фреймворк** | React 19 для всех пользовательских страниц и вкладок; общие сервисы и чистые вспомогательные функции остаются ES-модулями | | **Модульная система** | ES6 Modules (`import`/`export`) | | **Стили** | CSS (модульный подход) | | **Шрифт** | Системный стек без внешних font-CDN | | **Веб-сервер** | Apache httpd на Alpine со строгим CSP | Пошаговый план и актуальная точка продолжения находятся в -[`REACT_MIGRATION.md`](REACT_MIGRATION.md). Миграция выполняется по целым страницам и -разделам без изменения действующих URL и REST-контрактов. +[`REACT_MIGRATION.md`](REACT_MIGRATION.md). Все страницы и вкладки переведены на React +с сохранением действующих URL и REST-контрактов. --- @@ -102,14 +102,15 @@ frontend/ │ │ │ └── TeacherAbsencesTab.jsx # Отсутствия: реестр, мастер замены, пожелания и заявки на изменение │ │ ├── main.jsx # React entrypoint для `/admin/` │ │ └── testing/ # render-admin.jsx для DOM-проверок -│ └── shared/ # Общий React-слой для следующих переносимых страниц +│ └── shared/ # Общий React-слой всех кабинетов │ ├── api-client.js # requestJson/apiClient поверх auth-транспорта │ ├── auth-client.js # Восстановление сессии и logout с redirect +│ ├── use-sidebar.js # Десктопное сворачивание и мобильное меню │ ├── dates.js # Дата-математика (недели, ISO, форматирование) │ ├── session-policy.js # Фазы loading/ready/unauthorized/error и русские тексты │ ├── use-authorized-session.js # Хук проверки роли при монтировании страницы │ ├── runtime-entry.jsx # Единая точка экспорта общего runtime-chunk -│ ├── ui/ # AppErrorBoundary, AsyncState, FormAlert, AsyncCombobox, ScheduleOverview, ConfirmDialog, Pagination +│ ├── ui/ # CustomSelect, SidebarToggle, AppErrorBoundary, AsyncState, FormAlert, AsyncCombobox, ScheduleOverview, ConfirmDialog, Pagination │ └── testing/ # render-shared.jsx для DOM-проверок через renderToStaticMarkup ├── tests/ │ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика @@ -132,6 +133,7 @@ frontend/ │ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра │ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям │ ├── teacher-preferences.test.mjs # Календарь пожеланий, заявки и подсказки конструктора +│ ├── browser/ # Playwright: наведение, селекты и мобильное меню │ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile ├── index.html # 🔐 Страница авторизации (общая) ├── style.css # Стили страницы авторизации @@ -330,9 +332,15 @@ settings SPA не содержат неработающей кнопки бур `PageResponse`; размер страницы — 25, 50 или 100. `view-state.js` унифицирует загрузку со spinner, пустой результат и ошибку с кнопкой повторного запроса. - `AsyncCombobox` загружает варианты с сервера, сохраняет выбранный ID, поддерживает - клавиатуру и роли `combobox/listbox`. Он используется для групп, преподавателей, + клавиатуру и роли `combobox/listbox`. При новом вводе сразу исключаются устаревшие + результаты, включая ответы во время задержки поиска. Escape закрывает меню с возвратом + фокуса без нового запроса; уход фокуса за пределы компонента отменяет отложенный поиск. + Возврат стрелкой вверх сохраняет введённый текст. Он используется для групп, преподавателей, аудиторий и совместимых календарных графиков; предварительная загрузка вариантов не раскрывает меню. Студент больше не загружает полный список групп. +- Реестры пользователей, групп, дисциплин и заявок сохраняют выбранные параметры до + завершения запроса, чтобы быстрые изменения фильтров и сортировки не отменяли друг друга. + Сброс фильтров групп также отменяет отложенный поиск. - `dialog.js` заменяет нативные `confirm`, `prompt` и `alert`: возвращает фокус, удерживает его внутри окна, закрывается по Escape и может требовать причину. Нативных диалогов в admin/settings-модулях нет. @@ -414,11 +422,28 @@ SHA-256 style-хэшей с CSP. Там же закреплены безопас npm run build # собрать dist/vendor/otel.js и dist/react/login.js npm test # frontend unit/static tests npm run check # React-сборка, синтаксис auth/UI-модулей + все frontend-тесты +npm run check:browser # Проверка UI, асинхронного поиска и валидации в локальном Chromium ``` -React-сборка выполняется существующим esbuild без отдельного dev-сервера. Для страницы -входа установлен лимит 75 000 байт gzip; превышение завершает сборку ошибкой. При появлении -нескольких React entrypoint общий runtime будет вынесен в отдельный кэшируемый chunk. +React-сборка выполняется esbuild без отдельного dev-сервера. Общие зависимости вынесены +в кэшируемые chunk'и, вкладки админки загружаются через `React.lazy`. Холодный бюджет +входа — 85 000 байт gzip, остальных кабинетов — 90 000 байт. Для каждого entrypoint +считаются только его статические зависимости: lazy-вкладки и модули других страниц +не включаются в холодную загрузку. Превышение лимита завершает сборку ошибкой. + +Браузерные проверки требуют запущенной локальной версии в Docker и Chromium +(`/usr/bin/chromium`, другой путь задаётся через `CHROMIUM_PATH`). По умолчанию +используются `https://localhost` и демо-аккаунт `admin`; настройки можно передать через +`FRONTEND_TEST_BASE_URL`, `FRONTEND_TEST_LOGIN`, `FRONTEND_TEST_PASSWORD`. +Проверка пожеланий использует демо-аккаунт преподавателя; для другого аккаунта +задаются `FRONTEND_TEST_TEACHER_LOGIN` и `FRONTEND_TEST_TEACHER_PASSWORD`. +Проверки не сохраняют изменения данных: проверяются обе темы, доступные вкладки, +края кнопок, смена роли в форме, сброс фильтра, мобильное меню админки/настроек +и стабильность ячеек пожеланий преподавателя. Отдельные сценарии задерживают ответы +API для проверки совместного изменения роли и сортировки и отмены поиска после сброса. +`shared-controls.test.mjs` собирает текущие компоненты в изолированную браузерную форму: +проверяет устаревшие ответы и ошибки поиска, клавиатурное управление, уход фокуса +и валидацию обязательного селекта без записи данных в API. --- @@ -549,6 +574,32 @@ ID преподавателя берётся из восстановленног 4. **`modals.css`** — Модальные окна 5. **`departments-data.css`** — Стили создания кафедры/специальности +### Общие элементы управления после миграции + +Кнопки сохраняют область наведения: hover и active меняют цвет, границу и тень, +не смещая сам элемент через `translateY`. Это исключает мерцание у нижней кромки. +Пункты бокового меню и ячейки пожеланий преподавателя также не смещаются при наведении. +Карточки и боковая панель админки/настроек используют непрозрачные фоновые токены +без `backdrop-filter`; у карточек отключена анимация с сохраняемым `transform`. +Это убирает фоновые фильтруемые слои, на которых наблюдались цветные полосы при +перерисовке после наведения на меню. В развёрнутой панели не создаются избыточные +нативные подсказки `title`; подсказки остаются у скрытых подписей свёрнутого меню. +Волна нажатия страницы входа ограничена `.btn-submit`; админка больше не добавляет +ripple-узлы в React-разметку старым глобальным обработчиком. + +Одинарные селекты вкладок и пагинации используют React-компонент `CustomSelect`. +Подписи, пункты и `disabled` синхронизируются с нативным контролом после рендера; +пустой пункт доступен для сброса фильтров. Автоматическое оборачивание селектов +через `admin/js/dropdown.js` отключено. При отправке формы пустой обязательный +селект показывает «Выберите значение», получает фокус на видимой кнопке и помечается +`aria-invalid`; выбор значения снимает ошибку. Пример — в `docs/UI_COMPONENTS.md`. + +Оболочки админки и настроек используют `useSidebar` и `SidebarToggle`: +сворачивание десктопной панели хранится в `localStorage`, открытие мобильного +меню — отдельное временное состояние. Мобильная панель закрывается при выборе +раздела, клике по затемнению, Escape и смене размера экрана. Навигация развёрнутой +панели прокручивается, а её заголовок и нижние действия остаются доступными. + ### Темизация Общий `ui-foundation.css` подключается последним во всех пяти оболочках. diff --git a/docs/README.md b/docs/README.md index c4d33ae..e7c6a83 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ | Компонент | Технология | |-----------|-----------| | **Backend** | Java 17, Spring Boot 3.5.16 | -| **Frontend** | React 19 + переходные Vanilla JavaScript ES-модули + HTML/CSS | +| **Frontend** | React 19 для всех пользовательских страниц + общие ES-модули + HTML/CSS | | **База данных** | PostgreSQL (через Flyway миграции) | | **Контейнеризация** | Docker, Docker Compose | | **Продакшн** | Kubernetes, Caddy (реверс-прокси) | diff --git a/docs/UI_COMPONENTS.md b/docs/UI_COMPONENTS.md index 3c440b1..02fb7ba 100644 --- a/docs/UI_COMPONENTS.md +++ b/docs/UI_COMPONENTS.md @@ -4,45 +4,53 @@ --- -## 1. Стандартные одинарные списки (Custom Select Wrapper) +## 1. Одинарные списки на React -Этот компонент автоматически "оборачивает" любые стандартные теги `` в HTML: - -```html -
- - +
+ + setSelectedId(event.target.value)} + > + + {options.map(option => ( + + ))} +
``` -### Как это работает: -1. В файле `frontend/admin/js/dropdown.js` инициализируется глобальный **`MutationObserver`**. -2. Как только любой скрипт или загрузка страницы добавляет `` (но оставляет его доступным из JS!). - - Рисует поверх него красивый `div.custom-select-wrapper` с нужным текстом, иконкой-шевроном и эффектом размытия фона. - - Синхронизирует состояния (если вы выберете элемент в кастомном UI, он автоматически изменит `select.value` и кинет событие `change`). - -### Динамическое обновление списка (через JS): -Если вы подгружаете список с API, просто обновите `innerHTML` **нативного селекта**, как обычно: - -```javascript -const select = document.getElementById('my-new-select'); -select.innerHTML = ''; -``` -**Магия!** Экземпляр `CustomSelect` использует свой собственный внутренний `MutationObserver` для отслеживания изменений `