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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,15 @@ MINIO_ROOT_PASSWORD=changeme-minio-root
MINIO_BUCKET_NAME=local-bucket
MINIO_ACCESS_TOKEN=changeme-access
MINIO_SECRET_KEY=changeme-secret

# --- App ---
# First page on startup (handy for manual UI checks, e.g. /fxml/ResourceDetails.fxml).
INITIAL_PAGE_PATH=/fxml/LoginForm.fxml
# Run without a backend, on in-memory mock data (for UI testing). Window title gets [MOCK].
MOCK_DATA=false

# --- Integration tests (mvn test -Dgroups=integration -DexcludedGroups=) ---
# Seed admin of the local dev backend; used by *IntegrationTest (skipped if unset).
# Never commit real values here.
IT_ADMIN_EMAIL=admin@example.com
IT_ADMIN_PASSWORD=changeme
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["vscjava.vscode-lombok"]
}
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ git pull --rebase

## Запуск

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

### С реальным бэкендом

```bash
# 1. Инфраструктура бэкенда (из корня этого репозитория):
Expand All @@ -20,9 +22,32 @@ docker compose up -d postgres redis
mvn javafx:run
```

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

```bash
MOCK_DATA=true mvn javafx:run
```

Приложение стартует на in-memory данных (`services/mock`, окно помечено `[MOCK]`):
вход — любой непустой email/пароль, роль по префиксу email (`admin*` → ADMIN,
`moder*` → MODER, иначе USER). Засижены 3 пользователя и 2 ресурса с файлами,
скачивание пишет реальный файл-плейсхолдер.

### Переменные окружения (.env)

| Переменная | Дефолт | Назначение |
| ---------- | ------ | ---------- |
| `INITIAL_PAGE_PATH` | `/fxml/LoginForm.fxml` | Стартовая страница (удобно открывать страницу напрямую для проверки вёрстки) |
| `MOCK_DATA` | `false` | `true` — работать на моках без бэкенда |
| `IT_ADMIN_EMAIL` / `IT_ADMIN_PASSWORD` | — | Сид-админ локального бэкенда для интеграционных тестов (тесты сами читают `.env`; без пароля скипаются) |
| `POSTGRES_*`, `REDIS_PASSWORD`, `APP_PORT` | см. `.env.example` | Инфраструктура и бэкенд |
| `MINIO_*` | см. `.env.example` | Только с `docker compose --profile files up -d` |

Свои значения — в `.env` (скопировать из `.env.example`): приложение читает
переменные из окружения (`set -a && source .env && set +a` перед запуском),
интеграционные тесты подхватывают `.env` сами.

Откроется окно 800×600 со стартовой страницы (по умолчанию вход, `LoginForm.fxml`).

## Git hooks

Expand Down
7 changes: 6 additions & 1 deletion docs/страницы.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ UI не ходит в HTTP напрямую. Контроллер вызывае
| --- | -------------------------------------------- | ------------------------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------- | ------------- |
| 1 | Вход (email + пароль) | `/fxml/LoginForm.fxml` | гость (публичная) | Аутентификация через `AuthService`, после успеха → №2 | @vladcheck |
| 2 | Список ресурсов и сортировка (главная) | `/fxml/ResourceList.fxml` | `USER` | Поиск/фильтр/сортировка, пагинация `offset/count` через `ResourceService.search` | @miyagi18 |
| 3 | Просмотр метаданных документа | `/fxml/ResourceDetails.fxml` (новая) | `USER` | Только чтение: `ResourceService.getById` → `title, description, createdAt, updatedAt, uuid` | @vladcheck |
| 3 | Просмотр метаданных документа | `/fxml/ResourceDetails.fxml` | `USER` | Только чтение: `ResourceService.getById` → `title, description, createdAt, updatedAt, uuid` | @vladcheck |
| 4 | Форма ресурса (создание/редактирование) | `/fxml/ResourceForm.fxml` (новая) | `MODER` | `create/update` через `ResourceService`; удаление — действием из №2/№3 с подтверждением, без отдельной страницы | @miyagi18 |
| 5 | Список пользователей | `/fxml/UserList.fxml` (новая) | `ADMIN` | Поиск/фильтр через `UsersService.search` + `UserFilter`; удаление действием из списка | @deatheh |
| 6 | Форма пользователя (создание/редактирование) | `/fxml/UserForm.fxml` (новая) | `ADMIN` | `create` через `RegisterData`, `update` через `UpdateUserData`, `getById` для предзаполнения | @deatheh |
Expand Down Expand Up @@ -58,6 +58,11 @@ UI не ходит в HTTP напрямую. Контроллер вызывае
* №1: ✅ готово — `LoginFormController` только email + пароль, вызов `AuthService`.
* №2: ✅ переименование `Search.fxml → ResourceList.fxml` готово; наполнить список:
`ResourceService.search` + пагинация, фильтры `ResourceFilter(title, description)`.
* №3: ✅ готово — `ResourceDetailsController` (read-only): метаданные + таблица файлов
со скачиванием (`FileService.downloadFile`), вход из №2 — `Navigator.goTo(path, c -> ...)`
с `setResourceId(uuid)` + `load()`.
* Роли: `AuthServiceImpl.hasRole` сравнивает по уровню (`USER=0 < MODER=1 < ADMIN=2`),
поэтому `isModerator()` возвращает true и для `ADMIN` — соответствует RBAC выше.
* SoC: списки (№2, №5) не содержат логики форм; формы (№4, №6) работают в двух режимах `create/edit` по наличию `uuid`, валидация через существующие `validation/*Validator`.
* Кнопки create/edit/delete рендерятся по роли из `Session.getCurrentUser().role`, а не отдельными страницами.

Expand Down
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@
<version>2.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.cdimascio</groupId>
<artifactId>dotenv-java</artifactId>
<version>3.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
Expand Down Expand Up @@ -146,6 +152,11 @@
Run them explicitly:
mvn test -Dgroups=integration -DexcludedGroups= -->
<excludedGroups>${excludedGroups}</excludedGroups>
<!-- Path to the local .env file for integration tests (java-dotenv).
Missing file is fine: tests skip via assumeTrue. -->
<systemPropertyVariables>
<dotenv.path>${project.basedir}/.env</dotenv.path>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.DesktopApplicationClientJava.Api.Dto.response.ResourceDto;
import com.DesktopApplicationClientJava.Api.Dto.response.UserDto;
import com.DesktopApplicationClientJava.session.Session;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.io.ByteArrayOutputStream;
Expand Down Expand Up @@ -49,6 +50,9 @@ public ApiClient(Session session) {
.build();
this.objectMapper = new ObjectMapper();
this.objectMapper.registerModule(new JavaTimeModule());
// FIXME: temporary — backend returns firstname/lastname instead of firstName/lastName.
// Remove once backend uses camelCase; then unknown properties should fail fast again.
this.objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}

// ======================== AUTH ========================
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.DesktopApplicationClientJava.Api;

import com.DesktopApplicationClientJava.Api.Dto.error.ErrorResponse;
import lombok.Getter;

@Getter
public class ApiException extends RuntimeException {
private final ErrorResponse errorResponse;

Expand All @@ -10,10 +12,6 @@ public ApiException(ErrorResponse errorResponse) {
this.errorResponse = errorResponse;
}

public ErrorResponse getErrorResponse() {
return errorResponse;
}

public int getStatus() {
return errorResponse.status();
}
Expand Down
16 changes: 13 additions & 3 deletions src/main/java/com/DesktopApplicationClientJava/Program.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
import org.kordamp.bootstrapfx.BootstrapFX;

public class Program extends Application {
private static final String INITIAL_PAGE_PATH = "/fxml/LoginForm.fxml";
private static final String INITIAL_PAGE_PATH =
System.getenv().getOrDefault("INITIAL_PAGE_PATH", "/fxml/LoginForm.fxml");
private static final boolean MOCK_DATA =
Boolean.parseBoolean(System.getenv().getOrDefault("MOCK_DATA", "false"));
private static final double WINDOW_WIDTH = 800.0;
private static final double WINDOW_HEIGHT = 600.0;

private final Session session = new Session();
private final ApiClient apiClient = new ApiClient(session);
private final ServiceRegistry services = new ServiceRegistry(apiClient, session);
private final ServiceRegistry services = createServices();

public static void main(String[] args) {
launch(args);
Expand Down Expand Up @@ -48,13 +51,20 @@ public void start(Stage stage) throws IOException {
loadStyleSheet(scene, "/styles.css");

stage.setScene(scene);
stage.setTitle("JavaFX Application");
stage.setTitle("JavaFX Application" + (MOCK_DATA ? " [MOCK]" : ""));
stage.setWidth(WINDOW_WIDTH);
stage.setHeight(WINDOW_HEIGHT);
stage.centerOnScreen();
stage.show();
}

private ServiceRegistry createServices() {
if (MOCK_DATA) {
return ServiceRegistry.mock(session);
}
return new ServiceRegistry(apiClient, session);
}

private void loadStyleSheet(Scene scene, String path) {
var url = getClass().getResource(path);
if (url != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package com.DesktopApplicationClientJava.controllers;

import com.DesktopApplicationClientJava.controllers.utils.Controller;
import com.DesktopApplicationClientJava.entities.FileInfo;
import com.DesktopApplicationClientJava.entities.Resource;
import com.DesktopApplicationClientJava.services.ServiceException;
import com.DesktopApplicationClientJava.services.filter.ResourceFilter;
import com.DesktopApplicationClientJava.services.mock.MockService;
import java.io.File;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.stage.DirectoryChooser;
import javafx.stage.Window;
import lombok.Setter;

public class ResourceDetailsController extends Controller {

static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");

@FXML private Label titleLabel;
@FXML private Label descriptionLabel;
@FXML private Label createdLabel;
@FXML private Label updatedLabel;
@FXML private Label uuidLabel;
@FXML private Label errorLabel;
@FXML private Label statusLabel;
@FXML private TableView<FileInfo> filesTable;
@FXML private TableColumn<FileInfo, String> nameColumn;
@FXML private TableColumn<FileInfo, Long> sizeColumn;
@FXML private TableColumn<FileInfo, String> contentTypeColumn;
@FXML private TableColumn<FileInfo, Void> downloadColumn;
@FXML private Button backButton;

@Setter private UUID resourceId;

@FXML
private void initialize() {
hideError();
nameColumn.setCellValueFactory(new PropertyValueFactory<>("name"));
sizeColumn.setCellValueFactory(new PropertyValueFactory<>("size"));
contentTypeColumn.setCellValueFactory(new PropertyValueFactory<>("contentType"));
downloadColumn.setCellFactory(col -> new DownloadCell(this::onDownload));
autoSelectFirstInMockMode();
}

public void load() {
hideError();
try {
showResource(services.getResourceService().getById(resourceId));
} catch (ServiceException e) {
showError(e.getMessage());
}
}

@FXML
private void onBack(ActionEvent event) {
navigator.goTo("/fxml/ResourceList.fxml");
}

private void onDownload(FileInfo file) {
File dir = chooseDirectory(backButton.getScene().getWindow());
if (dir == null) {
return;
}
try {
Path saved = services.getFileService().downloadFile(resourceId, file.getUuid(), dir.toPath());
statusLabel.setText("Сохранено: " + saved.getFileName());
} catch (ServiceException e) {
showError(e.getMessage());
}
}

protected File chooseDirectory(Window owner) {
return new DirectoryChooser().showDialog(owner);
}

/**
* Dev-only fallback: when the page is opened without an id (e.g. straight via INITIAL_PAGE_PATH)
* against a mock service, show the first seeded resource so the UI is clickable without the
* resource list. Never triggers against the real backend.
*/
private void autoSelectFirstInMockMode() {
if (resourceId != null || !(services.getResourceService() instanceof MockService mock)) {
return;
}
mock.doIfInitialPage();
services.getResourceService().search(ResourceFilter.empty(), 0, 1).stream()
.map(Resource::getUuid)
.findFirst()
.ifPresent(
id -> {
setResourceId(id);
load();
});
}

private void showResource(Resource resource) {
titleLabel.setText(resource.getTitle());
descriptionLabel.setText(resource.getDescription());
createdLabel.setText(formatDateTime(resource.getCreatedAt()));
updatedLabel.setText(formatDateTime(resource.getUpdatedAt()));
uuidLabel.setText(resource.getUuid() == null ? "" : resource.getUuid().toString());
List<FileInfo> files = resource.getFiles() == null ? List.of() : resource.getFiles();
filesTable.getItems().setAll(files);
}

private static String formatDateTime(LocalDateTime value) {
return value == null ? "" : DATE_TIME_FORMAT.format(value);
}

private void showError(String message) {
errorLabel.setText(message);
errorLabel.setVisible(true);
errorLabel.setManaged(true);
}

private void hideError() {
errorLabel.setText("");
errorLabel.setVisible(false);
errorLabel.setManaged(false);
}

private static final class DownloadCell extends TableCell<FileInfo, Void> {
private final Button button = new Button("Скачать");

DownloadCell(Consumer<FileInfo> onDownload) {
button.getStyleClass().setAll("btn", "btn-default");
button.setOnAction(event -> onDownload.accept(getTableView().getItems().get(getIndex())));
}

@Override
protected void updateItem(Void item, boolean empty) {
super.updateItem(item, empty);
setGraphic(empty ? null : button);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,12 @@
import com.DesktopApplicationClientJava.services.ServicesAware;
import com.DesktopApplicationClientJava.session.Session;
import com.DesktopApplicationClientJava.session.SessionAware;
import lombok.Setter;

@Setter
public abstract class Controller implements SessionAware, NavigatorAware, ServicesAware {

protected Session session;
protected Navigator navigator;
protected ServiceRegistry services;

@Override
public void setSession(Session session) {
this.session = session;
}

@Override
public void setNavigator(Navigator navigator) {
this.navigator = navigator;
}

@Override
public void setServices(ServiceRegistry services) {
this.services = services;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.DesktopApplicationClientJava.entities;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Getter;
Expand All @@ -17,4 +18,5 @@ public class Resource {
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<FileInfo> files;
}
Loading
Loading