From 43bbcece7a901e94021e10bca8b227c8ba285ac2 Mon Sep 17 00:00:00 2001 From: kasun Date: Sun, 10 May 2026 03:30:36 +0200 Subject: [PATCH] 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, + }, + }, + }, + } })