` и устанавливается `HttpOnly` refresh-cookie.
+4. Access JWT хранится в `localStorage`; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш.
+5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
+6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
-Токены хранятся в `AuthSessionService` в памяти процесса backend. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, кроме `POST /api/auth/login`, и применяет аннотацию `@RequireRoles` на контроллерах и методах. Это означает, что UI-роль в `localStorage` больше не является единственной защитой: backend возвращает `401`, если токена нет, и `403`, если роли недостаточно.
+Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно.
-`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution.
+`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.
diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md
index 2fa695c..88722a7 100644
--- a/docs/BUSINESS_LOGIC.md
+++ b/docs/BUSINESS_LOGIC.md
@@ -57,9 +57,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
### Учебные группы (Student Groups)
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
-- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`
+- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
+- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
### Аудитории (Classrooms)
@@ -90,6 +91,8 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
+Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
+
### Перевод преподавателей между кафедрами
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
@@ -136,6 +139,12 @@ Bearer-токен проверяется на backend. Frontend-скрытие
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
+Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп.
+
+В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
+
+Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId` и `departmentId`, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` без группы или кафедры строятся через генерацию расписания преподавателя, чтобы не выполнять полный перебор групп.
+
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
@@ -167,6 +176,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
+- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
### Точечные изменения расписания
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
index 9e3b82d..bc3f180 100644
--- a/docs/DATABASE.md
+++ b/docs/DATABASE.md
@@ -48,6 +48,17 @@ erDiagram
TIMESTAMP created_at
TIMESTAMP updated_at
}
+
+ auth_refresh_tokens {
+ BIGSERIAL id PK
+ BIGINT user_id FK
+ VARCHAR tenant
+ VARCHAR token_hash UK
+ TIMESTAMP issued_at
+ TIMESTAMP expires_at
+ TIMESTAMP revoked_at
+ VARCHAR rotated_to_token_hash
+ }
education_forms {
BIGSERIAL id PK
@@ -292,6 +303,7 @@ erDiagram
student_groups ||--o{ schedule_rule_groups : "group_id"
student_groups ||--o{ student_group_calendar_assignments : "group_id"
users ||--o{ teacher_subjects : "user_id"
+ users ||--o{ auth_refresh_tokens : "user_id"
users ||--o{ teacher_department_assignments : "teacher_id"
departments ||--o{ teacher_department_assignments : "department_id"
users ||--o{ teacher_lesson_types : "user_id"
@@ -379,6 +391,22 @@ erDiagram
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
+#### `auth_refresh_tokens` — Refresh-сессии JWT
+| Колонка | Тип | Описание |
+|---------|-----|----------|
+| `id` | BIGSERIAL PK | ID refresh-сессии |
+| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
+| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
+| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
+| `issued_at` | TIMESTAMP | Дата выдачи |
+| `expires_at` | TIMESTAMP | Дата истечения |
+| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии |
+| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
+| `user_agent` | VARCHAR(512) | User-Agent клиента |
+| `ip_address` | VARCHAR(64) | IP-адрес клиента |
+
+Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый refresh-токен отзывается, а клиент получает новый refresh-cookie.
+
### Учебный процесс
#### `education_forms` — Формы обучения
@@ -401,6 +429,11 @@ erDiagram
| `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
+| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
+| `active_from` | DATE | Дата начала действия группы |
+| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
+| `archived_at` | TIMESTAMP | Дата и время архивирования |
+| `archive_reason` | TEXT | Причина архивирования |
#### `subgroups` — Подгруппы
| Колонка | Тип | Описание |
@@ -616,6 +649,8 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `version_group_id` | BIGINT | Группа версий одного правила |
| `change_reason` | TEXT | Причина изменения |
+`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
+
#### `schedule_rule_groups` — Группы правила
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -674,17 +709,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
-3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway
+3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
-> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных.
+> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
### Текущие миграции
| Файл | Описание |
|------|----------|
-| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
+| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index f7727e9..e3a3a74 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -222,11 +222,13 @@ public class AbsenceController {
### Правила
-1. **Никогда** не изменяйте уже закоммиченные файлы миграций
+1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
4. После добавления — перезапустите backend для применения
+Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
+
### Применение
```bash
@@ -252,11 +254,13 @@ com.magistr.app/
│ ├── TenantContext.java # ThreadLocal текущего тенанта
│ ├── TenantInterceptor.java # Определение тенанта из Host
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД
-│ ├── TenantDataSourceConfig.java # Spring-конфигурация
+│ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA
+│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
├── controller/ # REST-контроллеры
│ ├── AuthController.java
+│ ├── GlobalExceptionHandler.java
│ ├── ScheduleController.java
│ ├── ClassroomController.java
│ ├── DatabaseController.java
diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md
index c5b5f22..7792a3d 100644
--- a/docs/FRONTEND.md
+++ b/docs/FRONTEND.md
@@ -146,7 +146,7 @@ frontend/
### Особенности админских вкладок
-- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
+- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
@@ -171,19 +171,24 @@ frontend/
## API-клиент (`api.js`)
-Все HTTP-запросы проходят через обёртку `apiFetch()`:
+Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом:
```javascript
-export async function apiFetch(endpoint, method = 'GET', body = null) {
+export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
const response = await fetch(endpoint, {
method,
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json'
- },
- body: body ? JSON.stringify(body) : null
+ headers: getHeaders(body ? 'application/json' : null),
+ credentials: 'same-origin',
+ body: body ? JSON.stringify(body) : undefined
});
+ if (response.status === 401 && retryOnUnauthorized) {
+ const refreshed = await refreshAccessToken();
+ if (refreshed) return apiFetch(endpoint, method, body, false);
+ clearAuthState();
+ window.location.href = '/';
+ }
+
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
@@ -200,7 +205,7 @@ export const api = {
};
```
-Токен берётся из `localStorage.getItem('token')`.
+При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
---
@@ -211,11 +216,12 @@ export const api = {
1. Пользователь вводит логин/пароль
2. `script.js` отправляет `POST /api/auth/login`
3. При успехе сохраняет в `localStorage`:
- - `token` — UUID-токен
+ - `token` — access JWT
- `role` — роль пользователя
- `departmentId` — кафедра пользователя
- `userId` — ID пользователя для личного расписания преподавателя
-4. Перенаправляет на соответствующий интерфейс:
+4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript
+5. Перенаправляет на соответствующий интерфейс:
- `ADMIN` → `/admin/`
- `EDUCATION_OFFICE` → `/admin/#schedule-view`
- `DEPARTMENT` → `/admin/#department-workspace`
@@ -230,13 +236,13 @@ export const api = {
```javascript
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
- return token && role === 'ADMIN';
+ return getToken() && role === 'ADMIN';
}
```
### Выход
-Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`.
+Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`.
---
diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md
index 6c80225..0a23cea 100644
--- a/docs/INFRASTRUCTURE.md
+++ b/docs/INFRASTRUCTURE.md
@@ -25,13 +25,21 @@ docker network create proxy
```env
POSTGRES_USER=myuser
-POSTGRES_PASSWORD=supersecretpassword
+POSTGRES_PASSWORD=replace-with-local-password
+POSTGRES_DB=app_db
+JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
+JWT_ACCESS_TOKEN_TTL=15m
+JWT_REFRESH_TOKEN_TTL=7d
```
+`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
+
+Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.
+
### Dockerfile (Backend)
Backend собирается через multi-stage сборку Maven:
-1. Этап сборки: `maven:3-eclipse-temurin-17-alpine` → `mvn package`
+1. Этап сборки: `maven:3.9-eclipse-temurin-17` → `mvn package`
2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`
### Dockerfile (Frontend)
@@ -58,6 +66,16 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
| `frontend` | Deployment | Apache httpd |
| `tenants-config` | ConfigMap | JSON-список тенантов |
+### JWT настройки
+
+`app-config` задаёт TTL access/refresh-токенов и признак Secure-cookie:
+
+- `JWT_ACCESS_TOKEN_TTL=15m`
+- `JWT_REFRESH_TOKEN_TTL=7d`
+- `JWT_REFRESH_COOKIE_SECURE=true`
+
+`app-secret` задаёт `JWT_SECRET`. Его нельзя логировать или хранить в публичных артефактах как реальный продакшн-секрет.
+
### ConfigMap для тенантов
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.
diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js
index 5e96564..5df791b 100755
--- a/frontend/admin/js/api.js
+++ b/frontend/admin/js/api.js
@@ -1,38 +1,43 @@
-const token = localStorage.getItem('token');
+let refreshPromise = null;
+
+const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId'];
export function getToken() {
- return token;
+ return localStorage.getItem('token');
}
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
- return token && role === 'ADMIN';
+ return getToken() && role === 'ADMIN';
}
export function isAuthenticatedAsRole(expectedRole) {
const role = localStorage.getItem('role');
- return token && role === expectedRole;
+ return getToken() && role === expectedRole;
}
export function isAuthenticatedAsAny(roles) {
const role = localStorage.getItem('role');
- return token && roles.includes(role);
+ return getToken() && roles.includes(role);
}
function getHeaders(contentType = 'application/json') {
- const headers = {
- 'Authorization': `Bearer ${token}`
- };
+ const headers = {};
+ const token = getToken();
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
if (contentType) {
headers['Content-Type'] = contentType;
}
return headers;
}
-export async function apiFetch(endpoint, method = 'GET', body = null) {
+export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
const options = {
method,
- headers: getHeaders(body ? 'application/json' : null)
+ headers: getHeaders(body ? 'application/json' : null),
+ credentials: 'same-origin'
};
if (body) {
@@ -48,6 +53,15 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
data = null;
}
+ if (response.status === 401 && retryOnUnauthorized) {
+ const refreshed = await refreshAccessToken();
+ if (refreshed) {
+ return apiFetch(endpoint, method, body, false);
+ }
+ clearAuthState();
+ window.location.href = '/';
+ }
+
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
@@ -55,6 +69,57 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
return data;
}
+export async function refreshAccessToken() {
+ if (refreshPromise) {
+ return refreshPromise;
+ }
+
+ refreshPromise = (async () => {
+ const response = await fetch('/api/auth/refresh', {
+ method: 'POST',
+ credentials: 'same-origin'
+ });
+ const data = await response.json().catch(() => null);
+ if (!response.ok || !data?.token) {
+ return false;
+ }
+ storeAuthState(data);
+ return true;
+ })().finally(() => {
+ refreshPromise = null;
+ });
+
+ return refreshPromise;
+}
+
+export async function logout() {
+ try {
+ await fetch('/api/auth/logout', {
+ method: 'POST',
+ credentials: 'same-origin'
+ });
+ } catch (e) {
+ console.warn('Не удалось завершить серверную сессию:', e.message);
+ } finally {
+ clearAuthState();
+ }
+}
+
+export function storeAuthState(data) {
+ if (data.token) localStorage.setItem('token', data.token);
+ if (data.role) localStorage.setItem('role', data.role);
+ if (data.departmentId !== undefined && data.departmentId !== null) {
+ localStorage.setItem('departmentId', data.departmentId);
+ }
+ if (data.userId !== undefined && data.userId !== null) {
+ localStorage.setItem('userId', data.userId);
+ }
+}
+
+export function clearAuthState() {
+ AUTH_KEYS.forEach(key => localStorage.removeItem(key));
+}
+
export const api = {
get: (url) => apiFetch(url, 'GET'),
post: (url, body) => apiFetch(url, 'POST', body),
diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js
index 4b73c3d..812fbb2 100755
--- a/frontend/admin/js/main.js
+++ b/frontend/admin/js/main.js
@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
-import { isAuthenticatedAsAny } from './api.js';
+import { isAuthenticatedAsAny, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
@@ -143,9 +143,8 @@ document.addEventListener('click', (e) => {
});
// Logout
-btnLogout.addEventListener('click', () => {
- localStorage.removeItem('token');
- localStorage.removeItem('role');
+btnLogout.addEventListener('click', async () => {
+ await logout();
window.location.href = '/';
});
diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js
index b1fa96b..014627c 100644
--- a/frontend/admin/js/views/groups.js
+++ b/frontend/admin/js/views/groups.js
@@ -88,7 +88,7 @@ export async function initGroups() {
await loadGroups();
await loadSubgroups();
} catch (error) {
- groupsTbody.innerHTML = `| Ошибка загрузки данных: ${escapeHtml(error.message)} |
`;
+ groupsTbody.innerHTML = `| Ошибка загрузки данных: ${escapeHtml(error.message)} |
`;
subgroupsTbody.innerHTML = `| Ошибка загрузки данных: ${escapeHtml(error.message)} |
`;
}
}
@@ -103,13 +103,13 @@ export async function initGroups() {
async function loadGroups() {
try {
- allGroups = await api.get('/api/groups');
+ allGroups = await api.get('/api/groups?includeArchived=true');
applyGroupFilter();
populateGroupSelect();
populateSubgroupGroupSelect();
await loadAssignments();
} catch (error) {
- groupsTbody.innerHTML = `| Ошибка загрузки: ${escapeHtml(error.message)} |
`;
+ groupsTbody.innerHTML = `| Ошибка загрузки: ${escapeHtml(error.message)} |
`;
}
}
@@ -132,7 +132,7 @@ export async function initGroups() {
function renderGroups(groups) {
if (!groups || !groups.length) {
- groupsTbody.innerHTML = '| Нет групп |
';
+ groupsTbody.innerHTML = '| Нет групп |
';
return;
}
groupsTbody.innerHTML = groups.map(group => `
@@ -145,9 +145,12 @@ export async function initGroups() {
${group.course || '-'} |
${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))} |
${escapeHtml(group.specialtyProfileName || '-')} |
+ ${escapeHtml(statusLabel(group))} |
-
+ ${group.status === 'ARCHIVED'
+ ? ``
+ : ``}
|
`).join('');
@@ -232,24 +235,26 @@ export async function initGroups() {
function populateGroupSelect() {
const current = calendarGroupSelect.value;
+ const availableGroups = allGroups.filter(group => group.active);
calendarGroupSelect.innerHTML = '' +
- allGroups.map(group => ``).join('');
- if (current && allGroups.some(group => String(group.id) === String(current))) {
+ availableGroups.map(group => ``).join('');
+ if (current && availableGroups.some(group => String(group.id) === String(current))) {
calendarGroupSelect.value = current;
- } else if (allGroups.length) {
- calendarGroupSelect.value = String(allGroups[0].id);
+ } else if (availableGroups.length) {
+ calendarGroupSelect.value = String(availableGroups[0].id);
}
syncSelects(calendarGroupSelect);
}
function populateSubgroupGroupSelect() {
const current = subgroupGroupSelect.value;
+ const availableGroups = allGroups.filter(group => group.active);
subgroupGroupSelect.innerHTML = '' +
- allGroups.map(group => ``).join('');
- if (current && allGroups.some(group => String(group.id) === String(current))) {
+ availableGroups.map(group => ``).join('');
+ if (current && availableGroups.some(group => String(group.id) === String(current))) {
subgroupGroupSelect.value = current;
- } else if (allGroups.length) {
- subgroupGroupSelect.value = String(allGroups[0].id);
+ } else if (availableGroups.length) {
+ subgroupGroupSelect.value = String(availableGroups[0].id);
}
syncSelects(subgroupGroupSelect);
}
@@ -342,15 +347,26 @@ export async function initGroups() {
async function handleGroupTableClick(event) {
const btnDelete = event.target.closest('.btn-delete');
const btnEdit = event.target.closest('.btn-edit-group');
+ const btnRestore = event.target.closest('.btn-restore-group');
if (btnDelete) {
- if (!confirm('Удалить группу?')) return;
+ if (!confirm('Архивировать группу? История расписания сохранится.')) return;
try {
await api.delete('/api/groups/' + btnDelete.dataset.id);
await loadGroups();
await loadSubgroups();
} catch (error) {
- alert(error.message || 'Ошибка удаления');
+ alert(error.message || 'Ошибка архивирования');
+ }
+ }
+
+ if (btnRestore) {
+ try {
+ await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
+ await loadGroups();
+ await loadSubgroups();
+ } catch (error) {
+ alert(error.message || 'Ошибка восстановления');
}
}
@@ -566,6 +582,21 @@ export async function initGroups() {
return name ? `${code} — ${name}` : code;
}
+ function statusLabel(group) {
+ if (group.status === 'ARCHIVED') return 'Архив';
+ return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
+ }
+
+ function statusBadgeClass(group) {
+ if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
+ return 'badge-unavailable';
+ }
+ if (group.studyState === 'NOT_STARTED') {
+ return 'badge-ef';
+ }
+ return 'badge-available';
+ }
+
function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));
diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js
index 3f99b58..9a41f88 100644
--- a/frontend/admin/settings/js/main.js
+++ b/frontend/admin/settings/js/main.js
@@ -1,10 +1,11 @@
// Основной модуль страницы настроек
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
+import { getToken } from '../../js/api.js';
+
// Проверка авторизации
-const token = localStorage.getItem('token');
const role = localStorage.getItem('role');
-if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
+if (!getToken() || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
window.location.href = '/';
}
diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html
index 879269a..8a40df9 100755
--- a/frontend/admin/views/groups.html
+++ b/frontend/admin/views/groups.html
@@ -67,12 +67,13 @@
Курс |
Специальность |
Профиль |
+ Статус |
Действия |
- | Загрузка... |
+ Загрузка... |
diff --git a/frontend/script.js b/frontend/script.js
index cf3632f..d1455fa 100755
--- a/frontend/script.js
+++ b/frontend/script.js
@@ -127,23 +127,28 @@
hideAlert();
try {
- const response = await fetch('/api/auth/login', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- username: usernameInput.value.trim(),
- password: passwordInput.value,
- }),
+ const response = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({
+ username: usernameInput.value.trim(),
+ password: passwordInput.value,
+ }),
});
const data = await response.json();
- if (response.ok) {
- showAlert('Вход выполнен успешно!', 'success');
+ if (response.ok) {
+ showAlert('Вход выполнен успешно!', 'success');
- if (data.token) localStorage.setItem('token', data.token);
- if (data.role) localStorage.setItem('role', data.role);
- if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
+ localStorage.removeItem('token');
+ localStorage.removeItem('role');
+ localStorage.removeItem('departmentId');
+ localStorage.removeItem('userId');
+ if (data.token) localStorage.setItem('token', data.token);
+ if (data.role) localStorage.setItem('role', data.role);
+ if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
if (data.userId) localStorage.setItem('userId', data.userId);
const redirect = data.redirect || '/';
diff --git a/frontend/student/index.html b/frontend/student/index.html
index 34219aa..2108e18 100644
--- a/frontend/student/index.html
+++ b/frontend/student/index.html
@@ -292,19 +292,18 @@