Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@
git pull --rebase
```

## Запуск

Требования: JDK 25, Maven, запущенный бэкенд (иначе вход покажет ошибку соединения).

```bash
# 1. Инфраструктура бэкенда (из корня этого репозитория):
cp .env.example .env
docker compose up -d postgres redis

# 2. Приложение бэкенда (из каталога соседнего репозитория, http://localhost:8080):
./mvnw spring-boot:run -Dmaven.test.skip=true

# 3. Приложение (из корня этого репозитория):
mvn javafx:run
```

Откроется окно 800×600 со страницы входа (`LoginForm.fxml` — она же `INITIAL_PAGE_PATH`
в `Program.java`). Для проверки вёрстки отдельной страницы можно временно подменить
`INITIAL_PAGE_PATH` на нужный FXML.

## RBAC

В системе предусмотрено 3 роли: пользователь, модератор и администратор:
Expand Down
86 changes: 86 additions & 0 deletions docs/examples/how-to-interact-with-api-from-ui-via-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# how-to-interact-with-api-from-ui-via-middleware

```java
package com.DesktopApplicationClientJava.controllers;

import com.DesktopApplicationClientJava.controllers.utils.Controller;
import com.DesktopApplicationClientJava.entities.User;
import com.DesktopApplicationClientJava.services.ServiceException;
import com.DesktopApplicationClientJava.validation.EmailValidator;
import com.DesktopApplicationClientJava.validation.PasswordValidator;
import com.DesktopApplicationClientJava.validation.utils.ErrorMessages;
import com.dlsc.formsfx.model.structure.Field;
import com.dlsc.formsfx.model.structure.Form;
import com.dlsc.formsfx.model.structure.PasswordField;
import com.dlsc.formsfx.model.structure.Section;
import com.dlsc.formsfx.model.structure.StringField;
import com.dlsc.formsfx.model.validators.Validator;
import com.dlsc.formsfx.view.renderer.FormRenderer;
import java.util.List;

import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.layout.VBox;

public class LoginFormController extends Controller {

@FXML private VBox formContainer;
@FXML private Button submitButton;

private StringField email;
private PasswordField password;
private Form loginForm;

@FXML
private void initialize() {
email =
Field.ofStringType("")
.label(EmailValidator.FIELD_NAME)
.placeholder("youremail@example.com")
.required(ErrorMessages.Required)
.validate(toArray(EmailValidator.validators()));
password =
Field.ofPasswordType("")
.label(PasswordValidator.FIELD_NAME)
.required(ErrorMessages.Required)
.validate(toArray(PasswordValidator.validators()));

loginForm = Form.of(Section.of(email, password));
formContainer.getChildren().add(new FormRenderer(loginForm));
}

@FXML
private void onSubmit(ActionEvent event) {
// клиентская валидация через FormsFX до вызова сервера
boolean valid = email.validate() & password.validate();
if (!valid) {
return;
}

try {
// ← ВОТ ЗДЕСЬ ВЫЗОВ СЕРВИСА
User user = services.getAuthService().login(email.getValue(), password.getValue());
if (user == null) {
return;
}

// если дошли сюда — логин успешен, session уже обновлён сервисом
navigator.goTo("/fxml/ResourceList.fxml");

} catch (ServiceException e) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Ошибка входа");
alert.setHeaderText(null);
alert.setContentText(e.getMessage());
alert.showAndWait();
}
}

@SuppressWarnings("unchecked")
private static Validator<String>[] toArray(List<Validator<String>> validators) {
return validators.toArray(new Validator[0]);
}
}
```
82 changes: 72 additions & 10 deletions docs/страницы.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,80 @@

Маршрут — путь к FXML для `Navigator.goTo(...)`.

| № | Название страницы | Маршрут (FXML) | Доступ | Назначение |
| --- | -------------------------------------------- | ---------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------- |
| 1 | Вход (email + пароль) | `/fxml/LoginForm.fxml` | гость (публичная) | Аутентификация через `AuthService`, после успеха → №2 |
| 2 | Список ресурсов и сортировка (главная) | `/fxml/ResourceList.fxml` (переименовать из `Search.fxml`) | `USER` | Поиск/фильтр/сортировка, пагинация `offset/count` через `ResourceService.search` |
| 3 | Просмотр метаданных документа | `/fxml/ResourceDetails.fxml` (новая) | `USER` | Только чтение: `ResourceService.getById` → `title, description, createdAt, updatedAt, uuid` |
| 4 | Форма ресурса (создание/редактирование) | `/fxml/ResourceForm.fxml` (новая) | `MODER` | `create/update` через `ResourceService`; удаление — действием из №2/№3 с подтверждением, без отдельной страницы |
| 5 | Список пользователей | `/fxml/UserList.fxml` (новая) | `ADMIN` | Поиск/фильтр через `UsersService.search` + `UserFilter`; удаление действием из списка |
| 6 | Форма пользователя (создание/редактирование) | `/fxml/UserForm.fxml` (новая) | `ADMIN` | `create` через `RegisterData`, `update` через `UpdateUserData`, `getById` для предзаполнения |
## Паттерн взаимодействия с API

UI не ходит в HTTP напрямую. Контроллер вызывает сервис («middleware»):
`Controller → services.getXService() → ApiClient → HTTP + Session (Bearer)`.
Сервис кладёт пользователя/токены в `Session`, ошибки пробрасывает как
`ServiceException`, контроллер показывает их через `Alert`.
Пример: docs/examples/how-to-interact-with-api-from-ui-via-middleware.md.

| № | Название страницы | Маршрут (FXML) | Доступ | Назначение |
| --- | -------------------------------------------- | ----------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------- |
| 1 | Вход (email + пароль) | `/fxml/LoginForm.fxml` | гость (публичная) | Аутентификация через `AuthService`, после успеха → №2 |
| 2 | Список ресурсов и сортировка (главная) | `/fxml/ResourceList.fxml` | `USER` | Поиск/фильтр/сортировка, пагинация `offset/count` через `ResourceService.search` |
| 3 | Просмотр метаданных документа | `/fxml/ResourceDetails.fxml` (новая) | `USER` | Только чтение: `ResourceService.getById` → `title, description, createdAt, updatedAt, uuid` |
| 4 | Форма ресурса (создание/редактирование) | `/fxml/ResourceForm.fxml` (новая) | `MODER` | `create/update` через `ResourceService`; удаление — действием из №2/№3 с подтверждением, без отдельной страницы |
| 5 | Список пользователей | `/fxml/UserList.fxml` (новая) | `ADMIN` | Поиск/фильтр через `UsersService.search` + `UserFilter`; удаление действием из списка |
| 6 | Форма пользователя (создание/редактирование) | `/fxml/UserForm.fxml` (новая) | `ADMIN` | `create` через `RegisterData`, `update` через `UpdateUserData`, `getById` для предзаполнения |

## Интеграции страниц с эндпоинтами (через сервисы)

Базовый URL: `api.baseUrl` (`http://localhost:8080`).

| № | Действие UI | Сервис (middleware) | ApiClient → HTTP |
|---|-------------|---------------------|------------------|
| 1 | Войти | `AuthService.login(email, password)` | `POST /api/v1/auth/login` (`LoginRequest`), затем `GET /api/v1/user/me`; токены → `Session` |
| 1 | Выйти | `AuthService.logout()` | `POST /api/v1/auth/logout` (`RefreshTokenRequest`), затем `Session.clear()` |
| 2 | Поиск/фильтр/сортировка ресурсов | `ResourceService.search(filter, offset, count)` | `GET /api/v1/resource?title=&description=&offset=&count=&sortBy=createdAt&sortDir=desc` |
| 3 | Открыть метаданные | `ResourceService.getById(id)` | `GET /api/v1/resource/{id}` |
| 3 | Список/скачать/загрузить/удалить файлы | `FileService.{uploadFile, downloadFile, deleteFile}` | `POST /api/v1/resource/{id}/file` (multipart), `GET /api/v1/resource/{id}/file/{fileId}`, `DELETE /api/v1/resource/{id}/file/{fileId}` |
| 3 | Экспорт файлов ресурса | `ExportService.exportResourceFiles(id, dir)` | `GET /api/v1/admin/resource/{id}/files/export` (бинарный ответ → файл) |
| 4 | Создать ресурс | `ResourceService.create(title, description)` | `POST /api/v1/resource` (`CreateResourceRequest`) |
| 4 | Обновить ресурс | `ResourceService.update(id, title, description)` | `PUT /api/v1/resource/{id}` (`UpdateResourceRequest`) |
| 4 | Удалить ресурс (из №2/№3) | `ResourceService.delete(id)` | `DELETE /api/v1/resource/{id}` |
| 5 | Поиск/фильтр пользователей | `UsersService.search(filter, offset, count)` | `GET /api/v1/admin?email=&firstName=&lastName=&role=&enabled=&offset=&count=&sortBy=createdAt&sortDir=desc` |
| 5 | Удалить пользователя (из списка) | `UsersService.delete(id)` | `DELETE /api/v1/admin/{id}` |
| 5 | Экспорт пользователей/ресурсов | `ExportService.{exportUsers, exportResources}` | `GET /api/v1/admin/users/export`, `GET /api/v1/admin/resource/export` |
| 6 | Создать пользователя | `UsersService.create(RegisterData)` | `POST /api/v1/admin` (`RegisterRequest`: email, password, role, firstName, lastName) |
| 6 | Открыть пользователя для редактирования | `UsersService.getById(id)` | `GET /api/v1/admin/{id}` |
| 6 | Обновить пользователя | `UsersService.update(id, UpdateUserData)` | `PUT /api/v1/admin/{id}` (`UpdateUserRequest`: email, enabled, role, firstName, lastName) |

Примечания:
* `ApiClient` при 401 (кроме `/api/v1/auth/*`) один раз обновляет токены через
`POST /api/v1/auth/refresh` и повторяет запрос; если refresh нет/не удался —
исходный ответ уходит в `ServiceException`.
* Клиентская валидация (FormsFX + `validation/*Validator`) срабатывает до вызова
сервиса; серверные ошибки (`ApiException` → `ServiceException`) показывает контроллер.

## Примечания по реализации

* №1: упростить `LoginFormController` — удалить `firstName/lastName/thirdName`, оставить `email + password` + вызов `AuthService`/`LoginRequest` (сейчас там заглушка `authenticate()` с `Role.USER`).
* №2: переименование `Search.fxml → ResourceList.fxml` + переименование `SearchController → ResourceListController`; обновить `Program.INITIAL_PAGE_PATH` и `navigator.goTo("/fxml/Search.fxml")` в `LoginFormController`.
* №1: ✅ готово — `LoginFormController` только email + пароль, вызов `AuthService`.
* №2: ✅ переименование `Search.fxml → ResourceList.fxml` готово; наполнить список:
`ResourceService.search` + пагинация, фильтры `ResourceFilter(title, description)`.
* SoC: списки (№2, №5) не содержат логики форм; формы (№4, №6) работают в двух режимах `create/edit` по наличию `uuid`, валидация через существующие `validation/*Validator`.
* Кнопки create/edit/delete рендерятся по роли из `Session.getCurrentUser().role`, а не отдельными страницами.

## Интеграционные тесты (rough)

Стек: JUnit5 + AssertJ уже есть; TestFX headless настроен в `maven-surefire-plugin`
(`--enable-native-access=ALL-UNNAMED`). HTTP backend-а в тестах нет — ставим
стаб: WireMock (или подменный `ApiClient`) + TestFX для UI-слоя.

* IT-Auth (№1): мок `POST /api/v1/auth/login` → 200 с токенами + `GET /api/v1/user/me` → 200 `UserDto`;
проверить: `Session` заполнен, переход на `ResourceList`; кейс 401 → `ServiceException`, показан `Alert`, перехода нет.
* IT-ResourceList (№2): мок `GET /api/v1/resource?...` → список `ResourceDto`;
проверить: таблица/список отрисован, пагинация меняет `offset/count` в запросе;
кейс 403 для `USER` без прав — сообщение об ошибке, без падения.
* IT-ResourceDetails (№3): мок `GET /api/v1/resource/{id}` → 200 и 404;
проверить: поля метаданных показаны; 404 → `Alert` «не найден».
* IT-ResourceForm (№4): мок `POST /api/v1/resource` → 201 и `PUT /api/v1/resource/{id}` → 200;
проверить: create/edit режимы по наличию `uuid`; `DELETE /api/v1/resource/{id}` после подтверждения.
* IT-UserList/UserForm (№5–6, ADMIN): мок `GET /api/v1/admin?...` → список,
`POST /api/v1/admin` → 201, `PUT /api/v1/admin/{id}` → 200, `DELETE` → 204;
проверить: фильтр `email/role/enabled` уходит в query params; создание требует все поля `RegisterData`.
* IT-Files/Export (№3/№5): мок multipart `POST .../file` → 201 `FileDto`,
`GET .../file/{fileId}` → бинарник, `GET .../export` → бинарник;
проверить: файл сохранён в `destinationDir`, имя взято из `Content-Disposition`.
* IT-Refresh: мок защищённого `GET` → 401, затем `POST /api/v1/auth/refresh` → 200,
повторный `GET` → 200; проверить: запрос повторён с новым Bearer без участия UI.
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@
<artifactId>bootstrapfx-core</artifactId>
<version>0.4.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.23.0</version>
<scope>test</scope>
</dependency>
</dependencies>

<repositories></repositories>
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/DesktopApplicationClientJava/Program.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.kordamp.bootstrapfx.BootstrapFX;

public class Program extends Application {
private static final String INITIAL_PAGE_PATH = "/fxml/ResourceList.fxml";
private static final String INITIAL_PAGE_PATH = "/fxml/LoginForm.fxml";
private static final double WINDOW_WIDTH = 800.0;
private static final double WINDOW_HEIGHT = 600.0;

Expand Down Expand Up @@ -43,6 +44,7 @@ public void start(Stage stage) throws IOException {

Parent root = loader.load();
Scene scene = new Scene(root);
scene.getStylesheets().add(BootstrapFX.bootstrapFXStylesheet());
loadStyleSheet(scene, "/styles.css");

stage.setScene(scene);
Expand Down
Loading
Loading