From 2cbb8b74675750752fdd7a9f36ba23b61dacf998 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 22 Apr 2026 18:58:12 +0200 Subject: [PATCH 1/5] add CLAUDE.md with architecture and development guidance Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a13bb6a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Vaessl is an AI-powered integration bridge that accepts user text/image inputs, processes them through an LLM pipeline (via LiteLLM), and exports structured data to management systems (Homebox, WikiJS). The backend uses a provider pattern for extensibility, and the frontend is in early scaffolding stage. + +## Commands + +### Backend (Spring Boot + Gradle, inside `backend/`) +```bash +./gradlew build # compile and package +./gradlew test # run all tests +./gradlew test --tests com.vaessl.app.connection.ConnectionServiceTest # single test class +``` + +### Frontend (React + Vite, inside `frontend/`) +```bash +npm run dev # start dev server +npm run build # TypeScript check + Vite build +npm run lint # ESLint +npm run test # Vitest watch mode +npm run test:ui # Vitest visual dashboard +``` + +## Environment + +Copy `.env.local` (not committed) into `backend/` with: +- `DB_URL`, `DB_TEST_URL`, `DB_USERNAME`, `DB_PASSWORD` — PostgreSQL (test container on port 5434) +- `OPENAI_KEY`, `OPENAI_BASE_URL` — LiteLLM gateway (provider-agnostic, configured for gpt-4o-mini) + +## Architecture + +### Backend (`backend/src/main/java/com/vaessl/app/`) + +Three main modules: + +**`connection/`** — core business logic +- `ConnectionProvider` interface: each integrated app (Homebox, WikiJS) implements `login()` and declares its `ServiceType` +- `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType` +- Entity (`Connection`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns +- DTOs use `Map` for flexible cross-app credential/result exchange + +**`dto/`** — `ConnectionRequest` / `ConnectionResponse` + +**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice` + +### Frontend (`frontend/src/`) + +React 19 + TypeScript + Tailwind CSS, Vite 8 build. Currently basic scaffolding; no significant business logic yet. + +### Data & AI + +- PostgreSQL + pgvector (semantic search via embeddings) +- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it +- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app + +### Testing Strategy + +Integration tests spin up a **mirrored PostgreSQL container** on port 5434 (same schema as production). WireMock mocks external HTTP APIs (Homebox, WikiJS). Do not mock the database in integration tests — the mirrored container strategy exists specifically to catch schema/migration divergence. -- 2.52.0 From 0127706262288ddbe67735708d7efb2f454e51e7 Mon Sep 17 00:00:00 2001 From: kasun Date: Wed, 22 Apr 2026 19:03:06 +0200 Subject: [PATCH 2/5] added doc for adding claude code to path --- docs/02-Preparation/01-code-server-adjustments.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/02-Preparation/01-code-server-adjustments.md b/docs/02-Preparation/01-code-server-adjustments.md index 41bc345..30fe462 100644 --- a/docs/02-Preparation/01-code-server-adjustments.md +++ b/docs/02-Preparation/01-code-server-adjustments.md @@ -2,7 +2,7 @@ Before developing on code-server I configure a Dockerfile to install all packages needed for Spring Boot, Java and Vite. -I install openjdk 25, nodejs 24.x and yarn and set the environment variables for Java. +I install openjdk 25, nodejs 24.x and yarn and set the environment variables for Java (and the /config/.local/bin folder that gets used for tools like Claude Code). Since the linuxserver code-server image doesn't come with root access for its default user abc out of the box every privileged action will be baked in here: @@ -20,7 +20,7 @@ RUN apt update && apt install -y \ # Set Java Environment ENV JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 -ENV PATH="$JAVA_HOME/bin:${PATH}" +ENV PATH="/config/.local/bin:$JAVA_HOME/bin:${PATH}" ``` -- 2.52.0 From 43bbcece7a901e94021e10bca8b227c8ba285ac2 Mon Sep 17 00:00:00 2001 From: kasun Date: Sun, 10 May 2026 03:30:36 +0200 Subject: [PATCH 3/5] add session-based connection management and React dashboard Backend: adds JDBC session support, login/status/logout endpoints, and new DTOs (AuthResponse, ConnectionStatusResponse, LoginResult). Frontend replaces the Vite boilerplate with a Dashboard, ServiceCard, and ConnectModal backed by a typed API client. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + backend/build.gradle.kts | 8 +- .../com/vaessl/app/config/CorsConfig.java | 22 + .../com/vaessl/app/config/SessionConfig.java | 19 + .../app/connection/ConnectionController.java | 72 +++- .../app/connection/ConnectionProvider.java | 6 + .../app/connection/ConnectionService.java | 25 +- .../com/vaessl/app/connection/Endpoint.java | 4 +- .../connection/HomeBoxConnectionProvider.java | 5 + .../java/com/vaessl/app/dto/AuthResponse.java | 5 + .../app/dto/ConnectionStatusResponse.java | 10 + .../java/com/vaessl/app/dto/LoginResult.java | 5 + ...itional-spring-configuration-metadata.json | 12 + backend/src/main/resources/application.yaml | 6 + .../connection/ConnectionControllerTest.java | 157 +++++++ .../connection/HomeboxIntegrationTest.java | 57 +-- frontend/package-lock.json | 395 ++++++++++++++++++ frontend/package.json | 1 + frontend/src/App.css | 184 -------- frontend/src/App.tsx | 117 +----- frontend/src/api/client.ts | 21 + frontend/src/api/connections.ts | 11 + frontend/src/components/ConnectModal.scss | 113 +++++ frontend/src/components/ConnectModal.tsx | 84 ++++ frontend/src/components/Dashboard.scss | 40 ++ frontend/src/components/Dashboard.tsx | 59 +++ frontend/src/components/ServiceCard.scss | 119 ++++++ frontend/src/components/ServiceCard.tsx | 52 +++ frontend/src/types/connection.ts | 20 + frontend/vite.config.ts | 27 +- 30 files changed, 1307 insertions(+), 350 deletions(-) create mode 100644 .gitignore create mode 100644 backend/src/main/java/com/vaessl/app/config/CorsConfig.java create mode 100644 backend/src/main/java/com/vaessl/app/config/SessionConfig.java create mode 100644 backend/src/main/java/com/vaessl/app/dto/AuthResponse.java create mode 100644 backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java create mode 100644 backend/src/main/java/com/vaessl/app/dto/LoginResult.java create mode 100644 backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java delete mode 100644 frontend/src/App.css create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/connections.ts create mode 100644 frontend/src/components/ConnectModal.scss create mode 100644 frontend/src/components/ConnectModal.tsx create mode 100644 frontend/src/components/Dashboard.scss create mode 100644 frontend/src/components/Dashboard.tsx create mode 100644 frontend/src/components/ServiceCard.scss create mode 100644 frontend/src/components/ServiceCard.tsx create mode 100644 frontend/src/types/connection.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d74e21 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode/ diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 05bcab7..00c8b19 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -27,6 +27,7 @@ extra["springAiVersion"] = "2.0.0-M3" dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-session-jdbc") // implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-webmvc") @@ -40,7 +41,8 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-validation-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("org.wiremock:wiremock-standalone:3.12.0")} + testImplementation("org.wiremock:wiremock-standalone:3.12.0") + testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")} dependencyManagement { imports { @@ -48,6 +50,10 @@ dependencyManagement { } } +tasks.withType { + options.compilerArgs.add("-parameters") +} + tasks.withType { useJUnitPlatform() } diff --git a/backend/src/main/java/com/vaessl/app/config/CorsConfig.java b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java new file mode 100644 index 0000000..3144cbe --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java @@ -0,0 +1,22 @@ +package com.vaessl.app.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Value("${vaessl.frontend-url}") + private String frontendUrl; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(frontendUrl, "http://192.168.1.208:5173", "https://5173.code-server.kasuns.website") + .allowedMethods("GET", "POST", "DELETE", "OPTIONS") + .allowedHeaders("Content-Type", "Accept") + .allowCredentials(true); + } +} diff --git a/backend/src/main/java/com/vaessl/app/config/SessionConfig.java b/backend/src/main/java/com/vaessl/app/config/SessionConfig.java new file mode 100644 index 0000000..5b9f902 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/config/SessionConfig.java @@ -0,0 +1,19 @@ +package com.vaessl.app.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.web.http.CookieSerializer; +import org.springframework.session.web.http.DefaultCookieSerializer; + +@Configuration +public class SessionConfig { + + @Bean + public CookieSerializer cookieSerializer() { + DefaultCookieSerializer serializer = new DefaultCookieSerializer(); + serializer.setCookieMaxAge(Integer.MAX_VALUE); + serializer.setUseHttpOnlyCookie(true); + serializer.setSameSite("Lax"); + return serializer; + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java index ae9409c..3dfa944 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -1,13 +1,26 @@ package com.vaessl.app.connection; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import com.vaessl.app.dto.AuthResponse; import com.vaessl.app.dto.ConnectionRequest; -import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.dto.ConnectionStatusResponse; +import com.vaessl.app.dto.LoginResult; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -15,10 +28,63 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public class ConnectionController { + private static final String SUFFIX = "_CONNECTION_ID"; + private final ConnectionService connectionService; @PostMapping("/login") - public ResponseEntity loginResponse(@Valid @RequestBody ConnectionRequest request) { - return ResponseEntity.ok(connectionService.login(request)); + public ResponseEntity login( + @Valid @RequestBody ConnectionRequest request, + HttpServletRequest httpReq) { + + LoginResult result = connectionService.login(request); + + HttpSession session = httpReq.getSession(true); + session.setAttribute(request.serviceType() + SUFFIX, result.connectionId()); + + if (result.expiresAt() != null) { + long secs = Instant.now().until(result.expiresAt(), ChronoUnit.SECONDS); + session.setMaxInactiveInterval((int) Math.max(secs, 300)); + } + + return ResponseEntity.ok(new AuthResponse(request.serviceType(), result.expiresAt())); + } + + @GetMapping("/connections/status") + public ResponseEntity> getStatus(HttpServletRequest httpReq) { + HttpSession session = httpReq.getSession(false); + if (session == null) { + return ResponseEntity.ok(List.of()); + } + + List statuses = new ArrayList<>(); + Collections.list(session.getAttributeNames()).stream() + .filter(k -> k.endsWith(SUFFIX)) + .forEach(k -> { + String serviceType = k.replace(SUFFIX, ""); + Long id = (Long) session.getAttribute(k); + ConnectionStatusResponse status = connectionService.getConnectionStatus(serviceType, id); + if (status != null) statuses.add(status); + }); + + return ResponseEntity.ok(statuses); + } + + @DeleteMapping("/connections/{serviceType}") + public ResponseEntity logout( + @PathVariable("serviceType") String serviceType, + HttpServletRequest httpReq) { + + HttpSession session = httpReq.getSession(false); + if (session != null) { + session.removeAttribute(serviceType + SUFFIX); + boolean hasMore = Collections.list(session.getAttributeNames()).stream() + .anyMatch(k -> k.endsWith(SUFFIX)); + if (!hasMore) { + session.invalidate(); + } + } + + return ResponseEntity.noContent().build(); } } diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java index bd2c7d1..91a6adb 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -1,5 +1,7 @@ package com.vaessl.app.connection; +import java.time.Instant; + import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; @@ -16,4 +18,8 @@ public interface ConnectionProvider { ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); void updateToRepository(ConnectionEntity existing, ConnectionResponse response); + + default Instant getTokenExpiry(ConnectionEntity entity) { + return null; + } } diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java index f68edf7..fed0b7d 100644 --- a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -1,5 +1,6 @@ package com.vaessl.app.connection; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -8,6 +9,8 @@ import org.springframework.stereotype.Service; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.dto.ConnectionStatusResponse; +import com.vaessl.app.dto.LoginResult; import com.vaessl.app.exception.WrongServiceTypeException; @Service @@ -23,7 +26,7 @@ public class ConnectionService { this.cRepository = cRepository; } - public ConnectionResponse login(ConnectionRequest request) { + public LoginResult login(ConnectionRequest request) { ConnectionProvider provider = providerRegistry.get(request.serviceType()); @@ -37,13 +40,27 @@ public class ConnectionService { ConnectionEntity existing = provider.findUniqueConnectionEntry(request); + ConnectionEntity saved; if (existing != null) { provider.updateToRepository(existing, response); + saved = existing; } else { ConnectionEntity newEntity = provider.connectionToEntity(request, response); - cRepository.save(newEntity); + saved = cRepository.save(newEntity); } - return response; + return new LoginResult(saved.getId(), response.expiresAt()); } -} \ No newline at end of file + + public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) { + ConnectionEntity entity = cRepository.findById(connectionId).orElse(null); + if (entity == null) return null; + + ConnectionProvider provider = providerRegistry.get(serviceType); + Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null; + boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now()); + + return new ConnectionStatusResponse(serviceType, entity.getAppUrl(), + entity.getUsername(), expiresAt, connected); + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java index 1056771..d8b7426 100644 --- a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java @@ -1,7 +1,9 @@ package com.vaessl.app.connection; public enum Endpoint { - HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"); + HOMEBOX_LOGIN("/api/v1/users/login"), + LOGIN("/login"), + CONNECTION_STATUS("/connections/status"); private final String value; diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java index 051ea85..1c50baa 100644 --- a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -97,6 +97,11 @@ public class HomeBoxConnectionProvider implements ConnectionProvider { } } + @Override + public Instant getTokenExpiry(ConnectionEntity entity) { + return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null; + } + private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { } diff --git a/backend/src/main/java/com/vaessl/app/dto/AuthResponse.java b/backend/src/main/java/com/vaessl/app/dto/AuthResponse.java new file mode 100644 index 0000000..a16a725 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/dto/AuthResponse.java @@ -0,0 +1,5 @@ +package com.vaessl.app.dto; + +import java.time.Instant; + +public record AuthResponse(String serviceType, Instant expiresAt) {} diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java new file mode 100644 index 0000000..77f73a6 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionStatusResponse.java @@ -0,0 +1,10 @@ +package com.vaessl.app.dto; + +import java.time.Instant; + +public record ConnectionStatusResponse( + String serviceType, + String appUrl, + String username, + Instant expiresAt, + boolean connected) {} diff --git a/backend/src/main/java/com/vaessl/app/dto/LoginResult.java b/backend/src/main/java/com/vaessl/app/dto/LoginResult.java new file mode 100644 index 0000000..8e54eea --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/dto/LoginResult.java @@ -0,0 +1,5 @@ +package com.vaessl.app.dto; + +import java.time.Instant; + +public record LoginResult(Long connectionId, Instant expiresAt) {} diff --git a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..b3bc323 --- /dev/null +++ b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,12 @@ +{"properties": [ + { + "name": "spring.session.store-type", + "type": "java.lang.String", + "description": "A description for 'spring.session.store-type'" + }, + { + "name": "vaessl.frontend-url", + "type": "java.lang.String", + "description": "A description for 'vaessl.frontend-url'" + } +]} \ No newline at end of file diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index f4b136c..eba2fc2 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -22,6 +22,12 @@ spring: chat: options: model: gpt-4o-mini + session: + store-type: jdbc + jdbc: + initialize-schema: always server: servlet: context-path: /api +vaessl: + frontend-url: ${FRONTEND_URL:http://192.168.1.208:5173/} diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java new file mode 100644 index 0000000..906fd42 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionControllerTest.java @@ -0,0 +1,157 @@ +package com.vaessl.app.connection; + +import static com.vaessl.app.connection.Endpoint.*; +import static com.vaessl.app.connection.ServiceType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; + +import jakarta.servlet.http.Cookie; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest +@AutoConfigureMockMvc +@WireMockTest +class ConnectionControllerTest { + + @Autowired + MockMvc mockMvc; + + private static final String TEST_USER = "admin"; + private static final String TEST_PASS = "pw"; + private static final String LOGIN_PATH = LOGIN.getValue(); + private static final String STATUS_PATH = CONNECTION_STATUS.getValue(); + private static final String LOGOUT_PATH = "/connections/HOMEBOX"; + + private static final String VALID_HOMEBOX_RESPONSE = """ + { + "token": "fake-jwt-token", + "attachmentToken": "fake-attach", + "expiresAt": "2099-01-01T00:00:00Z" + } + """; + + private static final String EXPIRED_HOMEBOX_RESPONSE = """ + { + "token": "expired-token", + "attachmentToken": "fake-attach", + "expiresAt": "2000-01-01T00:00:00Z" + } + """; + + @Test + void shouldReturnEmptyListWhenNoActiveSession() throws Exception { + mockMvc.perform(get(STATUS_PATH)) + .andExpect(status().isOk()) + .andExpect(content().json("[]")); + } + + @Test + void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm) throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); + + MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(connectionRequestBody(wm))) + .andExpect(status().isOk()) + .andReturn(); + + Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + + mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue())) + .andExpect(jsonPath("$[0].username").value(TEST_USER)) + .andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl())) + .andExpect(jsonPath("$[0].connected").value(true)); + } + + @Test + void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm) throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE))); + + MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(connectionRequestBody(wm))) + .andExpect(status().isOk()) + .andReturn(); + + Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + + mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue())) + .andExpect(jsonPath("$[0].connected").value(false)) + .andExpect(jsonPath("$[0].expiresAt").value("2000-01-01T00:00:00Z")); + } + + @Test + void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); + + MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(connectionRequestBody(wm))) + .andExpect(status().isOk()) + .andReturn(); + + Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + + mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) + .andExpect(status().isNoContent()); + } + + @Test + void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception { + WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE))); + + MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(connectionRequestBody(wm))) + .andExpect(status().isOk()) + .andReturn(); + + Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION"); + + // Verify connected before logout + mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + + mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie)) + .andExpect(status().isNoContent()); + + // A new request (no session cookie, as in a fresh browser) returns no connections + mockMvc.perform(get(STATUS_PATH)) + .andExpect(status().isOk()) + .andExpect(content().json("[]")); + } + + @Test + void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception { + mockMvc.perform(delete(LOGOUT_PATH)) + .andExpect(status().isNoContent()); + } + + private String connectionRequestBody(WireMockRuntimeInfo wm) { + return """ + { + "appUrl": "%s", + "serviceType": "HOMEBOX", + "username": "%s", + "password": "%s" + } + """.formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS); + } +} diff --git a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java index 8646120..6003cbb 100644 --- a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -8,6 +8,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import com.github.tomakehurst.wiremock.http.Fault; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; @@ -41,18 +42,16 @@ class HomeboxIntegrationTest { } """; - private static final String MOCK_URL = "http://localhost:1234"; private static final String TEST_USER = "admin"; private static final String TEST_PASS = "pw"; /** * Returns Token and status code OK when login is successful. * - * @param wm - * the WiremockRuntimeInfo object + * @param wm the WiremockRuntimeInfo object */ @Test - void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { + void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { stubFor(post(HOMEBOX_LOGIN.getValue()) .willReturn(okJson(okJsonHomeboxResponse))); @@ -62,11 +61,8 @@ class HomeboxIntegrationTest { DocumentContext documentContext = JsonPath.parse(response.getBody()); - String token = documentContext.read("$.token"); - assertThat(token).isEqualTo("fake-jwt-token"); - - String attachmentToken = documentContext.read("$.extraResponseData.attachmentToken"); - assertThat(attachmentToken).isEqualTo("fake-attach"); + String serviceType = documentContext.read("$.serviceType"); + assertThat(serviceType).isEqualTo("HOMEBOX"); String expiresAt = documentContext.read("$.expiresAt", String.class); assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z"); @@ -76,8 +72,7 @@ class HomeboxIntegrationTest { /** * Tests the Unauthorized custom exception. * - * @param wm - * the WiremockRuntimeInfo object + * @param wm the WiremockRuntimeInfo object */ @Test void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { @@ -94,8 +89,7 @@ class HomeboxIntegrationTest { /** * Tests a server error from the external api. * - * @param wm - * the WiremockRuntimeInfo object + * @param wm the WiremockRuntimeInfo object */ @Test void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { @@ -113,14 +107,12 @@ class HomeboxIntegrationTest { * Checks when the service is unavailable or the app URL is wrong. */ @Test - void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong() { + void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) { - ConnectionRequest badRequest = new ConnectionRequest( - MOCK_URL, - HOMEBOX.getValue(), - TEST_USER, TEST_PASS, - false); + stubFor(post(HOMEBOX_LOGIN.getValue()) + .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); + ConnectionRequest badRequest = connectionRequest(wm); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); @@ -146,9 +138,9 @@ class HomeboxIntegrationTest { * unsupported. */ @Test - void shouldReturnWrongServiceTypeException() { + void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) { ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( - MOCK_URL, + wm.getHttpBaseUrl(), "wrong-service-type", TEST_USER, TEST_PASS, false); @@ -164,8 +156,7 @@ class HomeboxIntegrationTest { * Tests the succesfull persistance of Homebox credential response to the * database. * - * @param wm - * the WiremockRuntimeInfo object + * @param wm the WiremockRuntimeInfo object */ @Test void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) { @@ -176,26 +167,25 @@ class HomeboxIntegrationTest { ConnectionRequest request = connectionRequest(wm); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class); - - DocumentContext responseContext = JsonPath.parse(response.getBody()); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); assertThat(dbEntry).isNotNull(); - assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); assertThat(dbEntry.getUsername()).isEqualTo(request.username()); if (dbEntry instanceof HomeboxEntity hbE) { - assertThat(hbE.getToken()).isEqualTo(responseContext.read("$.token")); - assertThat(hbE.getAttachmentToken()).isEqualTo(responseContext.read("$.extraResponseData.attachmentToken")); - assertThat(hbE.getExpiresAt()).isEqualTo(responseContext.read("$.expiresAt")); + assertThat(hbE.getToken()).isEqualTo("fake-jwt-token"); + assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach"); + assertThat(hbE.getExpiresAt().toString()).hasToString("2026-04-26T02:23:13Z"); } } @Test - void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing() { - ConnectionRequest missingCredentials = new ConnectionRequest(MOCK_URL, HOMEBOX.getValue(), TEST_USER, null, + void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) { + ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, + null, false); ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, @@ -206,11 +196,10 @@ class HomeboxIntegrationTest { } /** - * Creates a valid connection request with a mock Api throuh + * Creates a valid connection request with a mock Api through * WireMockRuntimeInfo. * - * @param wm - * the WiremockRuntimeInfo object + * @param wm the WiremockRuntimeInfo object * @return a mock api connection request. */ private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb32e5f..884ea2d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "jsdom": "^29.0.1", + "sass": "^1.99.0", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1", @@ -840,6 +841,334 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1956,6 +2285,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2559,6 +2904,13 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3163,6 +3515,14 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -3412,6 +3772,20 @@ "license": "MIT", "peer": true }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3487,6 +3861,27 @@ "dev": true, "license": "MIT" }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index fec3c3c..61d06b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "jsdom": "^29.0.1", + "sass": "^1.99.0", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1", diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 46a5992..c30b522 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,120 +1,9 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' + +import { Dashboard } from './components/Dashboard' function App() { - const [count, setCount] = useState(0) - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- + ) } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..8db6af1 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,21 @@ +const BASE = import.meta.env.VITE_API_URL ?? '/api' + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + ...init, + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...init?.headers }, + }) + if (!res.ok) { + let message = `Request failed (${res.status})` + try { + const body = await res.json() + if (body?.detail) message = body.detail + else if (body?.title) message = body.title + } catch { + // ignore parse errors + } + throw new Error(message) + } + return res.status === 204 ? (undefined as T) : res.json() +} diff --git a/frontend/src/api/connections.ts b/frontend/src/api/connections.ts new file mode 100644 index 0000000..633e063 --- /dev/null +++ b/frontend/src/api/connections.ts @@ -0,0 +1,11 @@ +import { apiFetch } from './client' +import type { AuthResponse, ConnectionStatus, LoginRequest } from '../types/connection' + +export const login = (req: LoginRequest) => + apiFetch('/login', { method: 'POST', body: JSON.stringify(req) }) + +export const getStatuses = () => + apiFetch('/connections/status') + +export const logout = (serviceType: string) => + apiFetch(`/connections/${serviceType}`, { method: 'DELETE' }) diff --git a/frontend/src/components/ConnectModal.scss b/frontend/src/components/ConnectModal.scss new file mode 100644 index 0000000..b816391 --- /dev/null +++ b/frontend/src/components/ConnectModal.scss @@ -0,0 +1,113 @@ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 16px; +} + +.modal { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 32px; + width: 100%; + max-width: 420px; + box-shadow: var(--shadow); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + } + + &__title { + font-family: var(--heading); + font-size: 20px; + font-weight: 500; + color: var(--text-h); + margin: 0; + } + + &__close { + background: none; + border: none; + cursor: pointer; + color: var(--text); + font-size: 20px; + line-height: 1; + padding: 4px; + border-radius: 4px; + &:hover { color: var(--text-h); } + &:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + } + + &__form { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__field { + display: flex; + flex-direction: column; + gap: 5px; + } + + &__label { + font-size: 13px; + font-weight: 500; + color: var(--text-h); + } + + &__input { + font-size: 15px; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text-h); + font-family: var(--sans); + transition: border-color 0.2s; + width: 100%; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); + } + } + + &__error { + font-size: 13px; + color: #ef4444; + padding: 8px 12px; + background: rgba(239, 68, 68, 0.08); + border-radius: 6px; + border: 1px solid rgba(239, 68, 68, 0.2); + } + + &__submit { + margin-top: 4px; + padding: 10px 20px; + font-size: 15px; + font-weight: 500; + border-radius: 6px; + border: none; + background: var(--accent); + color: #fff; + cursor: pointer; + transition: opacity 0.2s; + align-self: flex-end; + min-width: 100px; + + &:hover:not(:disabled) { opacity: 0.85; } + &:disabled { opacity: 0.6; cursor: not-allowed; } + &:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + } +} diff --git a/frontend/src/components/ConnectModal.tsx b/frontend/src/components/ConnectModal.tsx new file mode 100644 index 0000000..902a665 --- /dev/null +++ b/frontend/src/components/ConnectModal.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState, type SyntheticEvent } from 'react' +import { login } from '../api/connections' +import type { LoginRequest } from '../types/connection' +import './ConnectModal.scss' + +interface Props { + serviceType: string + label: string + onClose: () => void + onSuccess: () => void +} + +export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonly) { + const [appUrl, setAppUrl] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const firstInputRef = useRef(null) + + const dialogRef = useRef(null) + + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + dialog.showModal() + + const handleCancel = (e: Event) => { + e.preventDefault() + onClose() + } + dialog.addEventListener('cancel', handleCancel) + return () => dialog.removeEventListener('cancel', handleCancel) + }, [onClose]) + + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + try { + const req: LoginRequest = { appUrl, serviceType, username, password, stayLoggedIn: true } + await login(req) + onSuccess() + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed') + } finally { + setLoading(false) + } + } + + return ( + +
+ + +
+
+
+ + setAppUrl(e.target.value)} required /> +
+
+ + setUsername(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required /> +
+ {error &&

{error}

} + +
+
+ ) +} diff --git a/frontend/src/components/Dashboard.scss b/frontend/src/components/Dashboard.scss new file mode 100644 index 0000000..99c7f5f --- /dev/null +++ b/frontend/src/components/Dashboard.scss @@ -0,0 +1,40 @@ +.dashboard { + padding: 40px 40px 60px; + display: flex; + flex-direction: column; + align-items: stretch; + flex: 1; + + @media (max-width: 768px) { + padding: 24px 20px 40px; + } + + &__header { + margin-bottom: 32px; + } + + &__title { + font-size: 32px; + letter-spacing: -0.5px; + margin: 0 0 4px; + + @media (max-width: 768px) { + font-size: 24px; + } + } + + &__section-label { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text); + margin: 0 0 12px; + } + + &__cards { + display: flex; + flex-direction: column; + gap: 12px; + } +} diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx new file mode 100644 index 0000000..b75a625 --- /dev/null +++ b/frontend/src/components/Dashboard.tsx @@ -0,0 +1,59 @@ +import { useState, useEffect } from "react" +import { getStatuses, logout } from "../api/connections" +import type { ConnectionStatus } from "../types/connection" +import { ServiceCard } from "./ServiceCard" +import { ConnectModal } from "./ConnectModal" +import "./Dashboard.scss" + +const SERVICES = [ + { serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' }, +] as const + +export function Dashboard() { + const [statuses, setStatuses] = useState([]) + const [openModal, setOpenModal] = useState(null) + + const refresh = () => { + getStatuses().then(setStatuses).catch(() => { }) + } + + useEffect(() => { refresh() }, []) + + const handleDisconnect = (serviceType: string) => { + logout(serviceType).then(refresh).catch(() => { }) + } + + const activeModal = SERVICES.find(s => s.serviceType === openModal) + + return ( +
+
+

Vaessl Dashboard

+
+ +

Services

+
+ {SERVICES.map(({ serviceType, label, icon }) => ( + s.serviceType === serviceType) ?? null} + onConnect={() => setOpenModal(serviceType)} + onDisconnect={() => handleDisconnect(serviceType)} + /> + ))} +
+ + {activeModal && ( + setOpenModal(null)} + onSuccess={() => { setOpenModal(null); refresh() }} + /> + )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ServiceCard.scss b/frontend/src/components/ServiceCard.scss new file mode 100644 index 0000000..b35c55c --- /dev/null +++ b/frontend/src/components/ServiceCard.scss @@ -0,0 +1,119 @@ +.service-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); + + &__info { + display: flex; + align-items: center; + gap: 16px; + } + + &__icon { + width: 40px; + height: 40px; + border-radius: 8px; + background: var(--accent-bg); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + } + + &__name { + font-family: var(--heading); + font-size: 17px; + font-weight: 500; + color: var(--text-h); + margin: 0 0 10px; + text-align: left; + + } + + &__meta { + font-size: 13px; + color: var(--text); + margin: 0; + display: flex; + align-items: center; + gap: 8px; + } + + &__badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + padding: 2px 8px; + border-radius: 999px; + + &::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + } + + &--connected { + color: #16a34a; + + &::before { + background: #16a34a; + } + } + + &--disconnected { + color: var(--text); + + &::before { + background: var(--border); + } + } + } + + &__actions { + display: flex; + gap: 8px; + } + + &__btn { + font-size: 14px; + font-weight: 500; + padding: 7px 16px; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + transition: box-shadow 0.2s, opacity 0.2s; + + &:hover { + opacity: 0.85; + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + &--connect { + background: var(--accent); + color: #fff; + } + + &--disconnect { + background: transparent; + color: var(--text); + border-color: var(--border); + + &:hover { + border-color: #ef4444; + color: #ef4444; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/components/ServiceCard.tsx b/frontend/src/components/ServiceCard.tsx new file mode 100644 index 0000000..3b1bc5d --- /dev/null +++ b/frontend/src/components/ServiceCard.tsx @@ -0,0 +1,52 @@ +import type { ConnectionStatus } from '../types/connection' +import './ServiceCard.scss' + +interface Props { + serviceType: string + label: string + icon: string + status: ConnectionStatus | null + onConnect: () => void + onDisconnect: () => void +} + +export function ServiceCard({ serviceType: _serviceType, label, icon, status, onConnect, onDisconnect }: Readonly) { + const connected = status?.connected ?? false + + const formatExpiry = (iso: string | null) => { + if (!iso) return null + const d = new Date(iso) + return d.toLocaleDateString(undefined, { dateStyle: 'medium' }) + } + + return ( +
+
+
{icon}
+
+

{label}

+

+ + {connected ? 'Connected' : 'Not connected'} + + {connected && status?.username && {status.username}} + {connected && status?.expiresAt && ( + · expires {formatExpiry(status.expiresAt)} + )} +

+
+
+
+ {connected ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/frontend/src/types/connection.ts b/frontend/src/types/connection.ts new file mode 100644 index 0000000..1e77aab --- /dev/null +++ b/frontend/src/types/connection.ts @@ -0,0 +1,20 @@ +export interface LoginRequest { + appUrl: string + serviceType: string + username: string + password: string + stayLoggedIn: boolean +} + +export interface AuthResponse { + serviceType: string + expiresAt: string +} + +export interface ConnectionStatus { + serviceType: string + appUrl: string + username: string + expiresAt: string | null + connected: boolean +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 04e405a..0bd9520 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,21 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' -// https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], - server: { - host: '0.0.0.0', - port: 5173, - allowedHosts: ['5173.code-server.kasuns.website'], - }, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + + return { + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + allowedHosts: env.FRONTEND_URL ? [env.FRONTEND_URL] : [], + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + } }) -- 2.52.0 From 2c766b10a3c3ed67eec2c246cf13ba3a4e744f8d Mon Sep 17 00:00:00 2001 From: kasun Date: Sun, 10 May 2026 03:32:16 +0200 Subject: [PATCH 4/5] added doc for claude code implementation --- .../07-claude-code-feasibility.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/02-Preparation/07-claude-code-feasibility.md diff --git a/docs/02-Preparation/07-claude-code-feasibility.md b/docs/02-Preparation/07-claude-code-feasibility.md new file mode 100644 index 0000000..2f2c884 --- /dev/null +++ b/docs/02-Preparation/07-claude-code-feasibility.md @@ -0,0 +1,172 @@ +**vaessl: Claude Code feasability** + +This is a showcase of how useful Claude Code can be for an app such as vaessl. There were very little steps to do to achieve an entire feature. This was achieved with a standard paid plan over 2 days. I never hit the limits and I must have used around 300k tokens likely a little more. Not only was I able to setup Java classes and React componentes. Claude Code pretty much nailed the CSS for the app. After minor adjustments for styling, CORS configurations, solving SonarQube warnings and generating additional integration tests I was good to commit the changes. The following documentation was also generated via CC. + +The entire commit can be reviewed under following hash 43bbcece7a901e94021e10bca8b227c8ba285ac2. + + +# What the feature achieves + + Users can connect Vaessl to external services (starting with Homebox) by entering their credentials in a UI. The backend authenticates against the remote service, persists the + connection in the database, and issues an HTTP session cookie. The frontend dashboard reflects live connection status and lets users disconnect. + + --- + ## Backend + + **build.gradle.kts** + + Added spring-boot-starter-session-jdbc (and its test counterpart). This enables Spring Session to store HTTP sessions in the PostgreSQL database rather than in-memory, so + sessions survive server restarts and work correctly in multi-instance deployments. + + --- + **application.yaml** + + Two additions: + - spring.session.store-type: jdbc activates the JDBC session store added above. + - server.servlet.context-path: /api prefixes all endpoints with /api, keeping the API cleanly separated from the frontend when served from the same host. + - vaessl.frontend-url externalizes the frontend origin so CORS can be configured without hardcoding. + + --- + **config/CorsConfig.java (new)** + + Spring MVC CORS configuration. The frontend runs on a different port/domain than the backend, so without this every browser request would be blocked. Key detail: + allowCredentials(true) is required for the session cookie to be sent cross-origin — this is why the allowed origins are explicit (wildcards are forbidden when credentials are + allowed). The three origins cover local dev, a LAN IP, and a tunnelled code-server URL. + + --- + **config/SessionConfig.java (new)** + + Customises the session cookie that Spring Session issues. HttpOnly prevents JavaScript from reading it (XSS mitigation). SameSite=Lax allows the cookie to travel with + cross-site navigations (needed for the separate frontend origin) while blocking CSRF from third-party sites. CookieMaxAge = Integer.MAX_VALUE keeps the cookie persistent in the + browser across tabs/restarts; the actual session lifetime is controlled per-login in the controller. + + --- + **META-INF/additional-spring-configuration-metadata.json (new)** + + Registers the custom vaessl.frontend-url and spring.session.store-type properties with the IDE. This gives autocomplete and documentation hints in application.yaml — it has no + runtime effect. + + --- + **dto/LoginResult.java (new)** + + An internal record (connectionId, expiresAt) used as the return type of ConnectionService.login(). It carries back the database ID of the saved connection and the token expiry + so the controller can use both without coupling to the entity layer. + + --- + **dto/AuthResponse.java (new)** + + The JSON body returned to the client after a successful login. It intentionally contains only serviceType and expiresAt — no tokens, no internal IDs — because all sensitive + state lives server-side in the session and the database. + + --- + **dto/ConnectionStatusResponse.java (new)** + + The JSON body returned by the status endpoint for each active connection. Includes enough for the UI to show: which service, the URL it's connected to, the logged-in username, + when the token expires, and whether it is currently considered connected (expiry has not passed). + + --- + **connection/ConnectionProvider.java (modified)** + + The provider interface gained five new methods: + - checkCredentials — validates the request before any network call is made. + - findUniqueConnectionEntry — looks up an existing database row for this user+URL combination. + - connectionToEntity / updateToRepository — split "create new row" from "refresh token on existing row", so re-logging in updates the token rather than creating a duplicate. + - getTokenExpiry — lets each provider expose its token lifetime (defaults to null meaning no expiry check). + + This keeps all provider-specific logic inside the provider, not scattered across the service. + + --- + **connection/ConnectionService.java (modified)** + + login() was refactored to use the new provider methods and now returns LoginResult instead of void. The upsert logic (find existing → update or create new) lives here. A new + method getConnectionStatus() was added: it loads the entity by ID, asks the provider for its expiry, and returns the status DTO. The ID-based lookup is safe here because the + controller already resolved the ID from the session. + + --- + **connection/ConnectionController.java (modified)** + + Three endpoints now exist: + + - POST /login — calls ConnectionService.login(), stores the returned connectionId in the HTTP session under a key like HOMEBOX_CONNECTION_ID, sets the session timeout to match + the token expiry (minimum 5 minutes), and responds with AuthResponse. + - GET /connections/status — reads all *_CONNECTION_ID keys from the session and calls getConnectionStatus for each, returning a list. Returns an empty list if no session exists + (not an error). + - DELETE /connections/{serviceType} — removes the specific key from the session. If no connections remain, the session is invalidated entirely. + + --- + **connection/HomeBoxConnectionProvider.java (modified)** + + Implements the new interface methods: + - checkCredentials validates that username and password are present before touching the network. + - findUniqueConnectionEntry queries the repository by appUrl + username. + - connectionToEntity delegates to HomeboxEntity.from(...). + - updateToRepository pattern-matches on HomeboxEntity and refreshes token, expiresAt, and attachmentToken. + - getTokenExpiry returns the stored expiresAt from the HomeboxEntity. + + --- + **connection/Endpoint.java (modified)** + + Added CONNECTION_STATUS("/connections/status") to the enum, alongside the existing constants, so the integration test can reference the status endpoint without a magic string. + + --- + ##Frontend + + types/connection.ts (new) + + TypeScript interfaces that mirror the backend DTOs exactly: LoginRequest, AuthResponse, ConnectionStatus. This is the single source of truth for shapes shared across the + frontend — the API layer and UI components both import from here. + + --- + **api/client.ts (new)** + + A thin apiFetch wrapper around the native fetch API. Two important behaviours: + - credentials: 'include' — sends the session cookie on every request (required for cross-origin session auth). + - Error normalisation — on non-2xx responses it tries to extract a detail or title field from the JSON body (standard Spring error format) and throws it as an Error with a + human-readable message. + - 204 No Content is returned as undefined cast to T, avoiding a JSON parse error on empty bodies (used by the DELETE endpoint). + + --- + **api/connections.ts (new)** + + Three typed API call functions — login, getStatuses, logout — each wrapping the corresponding endpoint via apiFetch. The component layer calls these directly; they are not + aware of the session cookie mechanism. + + --- + **components/Dashboard.tsx (new)** + + The top-level page component. It: + - Defines the static SERVICES registry (currently just Homebox) — adding a new service means adding one entry here. + - Fetches connection statuses on mount via useEffect. + - Manages which connect-modal is open via openModal state. + - Passes connect/disconnect callbacks down to ServiceCard and on success calls refresh() to re-fetch statuses. + + --- + **components/ServiceCard.tsx (new)** + + A presentational card for a single service. Displays the service icon, name, a coloured connected/disconnected badge, the logged-in username, and token expiry date. Shows a + "Connect" or "Disconnect" button depending on state. It receives everything as props — no API calls inside. + + --- + **components/ConnectModal.tsx (new)** + + A modal dialog with a login form (App URL, username, password). On submit it calls login() from the API layer and calls onSuccess if it succeeds, or renders the error message + if it fails. Auto-focuses the first field on open and closes on Escape key — both accessibility practices. Clicking the overlay also closes it. + + --- + **App.tsx (modified)** + + Replaced the previous placeholder content with a single render. The app entry point is now minimal by design — all layout and state live inside Dashboard. + + --- + ## Summary flow + + User clicks "Connect" → ConnectModal form submitted + → POST /api/login (with credentials) + → backend authenticates against Homebox + → saves/updates connection row in DB + → stores connection ID in HTTP session (JDBC-backed) + → returns AuthResponse with expiry + → Dashboard calls getStatuses() + → GET /api/connections/status (session cookie sent) + → backend reads session, looks up DB rows, returns status list + → ServiceCard shows "Connected" with username + expiry \ No newline at end of file -- 2.52.0 From a8e39d8f09c5b38ca8dcdb05cf2dc8c35cbad725 Mon Sep 17 00:00:00 2001 From: kasun Date: Sun, 10 May 2026 03:49:41 +0200 Subject: [PATCH 5/5] changed cors config to use env variables --- .../src/main/java/com/vaessl/app/config/CorsConfig.java | 9 ++++++--- .../additional-spring-configuration-metadata.json | 9 +++++++-- backend/src/main/resources/application.yaml | 5 +++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/com/vaessl/app/config/CorsConfig.java b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java index 3144cbe..945686a 100644 --- a/backend/src/main/java/com/vaessl/app/config/CorsConfig.java +++ b/backend/src/main/java/com/vaessl/app/config/CorsConfig.java @@ -8,13 +8,16 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { - @Value("${vaessl.frontend-url}") - private String frontendUrl; + @Value("${vaessl.frontend-local-url}") + private String frontendLocalUrl; + + @Value("${vaessl.frontend-public-url}") + private String frontendPublicUrl; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins(frontendUrl, "http://192.168.1.208:5173", "https://5173.code-server.kasuns.website") + .allowedOrigins(frontendLocalUrl, frontendPublicUrl) .allowedMethods("GET", "POST", "DELETE", "OPTIONS") .allowedHeaders("Content-Type", "Accept") .allowCredentials(true); diff --git a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json index b3bc323..57fd745 100644 --- a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -5,8 +5,13 @@ "description": "A description for 'spring.session.store-type'" }, { - "name": "vaessl.frontend-url", + "name": "vaessl.frontend-local-url", "type": "java.lang.String", - "description": "A description for 'vaessl.frontend-url'" + "description": "A description for 'vaessl.frontend-local-url'" + }, + { + "name": "vaessl.frontend-public-url", + "type": "java.lang.String", + "description": "A description for 'vaessl.frontend-public-url'" } ]} \ No newline at end of file diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index eba2fc2..f9c6a95 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: - "optional:file:backend/.env.local[.properties]" - "optional:file:vaessl/backend/.env.local[.properties]" datasource: - url : ${DB_URL} + url: ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: ${PG_DRIVER_CLASS_NAME} @@ -30,4 +30,5 @@ server: servlet: context-path: /api vaessl: - frontend-url: ${FRONTEND_URL:http://192.168.1.208:5173/} + frontend-local-url: ${FRONTEND_LOCAL_URL} + frontend-public-url: ${FRONTEND_PUBLIC_URL} -- 2.52.0