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 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 03:30:36 +02:00
parent 0127706262
commit 43bbcece7a
30 changed files with 1307 additions and 350 deletions
@@ -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);
}
}
@@ -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;
}
}
@@ -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<ConnectionResponse> loginResponse(@Valid @RequestBody ConnectionRequest request) {
return ResponseEntity.ok(connectionService.login(request));
public ResponseEntity<AuthResponse> 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<List<ConnectionStatusResponse>> getStatus(HttpServletRequest httpReq) {
HttpSession session = httpReq.getSession(false);
if (session == null) {
return ResponseEntity.ok(List.of());
}
List<ConnectionStatusResponse> 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<Void> 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();
}
}
@@ -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;
}
}
@@ -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());
}
}
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);
}
}
@@ -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;
@@ -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) {
}
@@ -0,0 +1,5 @@
package com.vaessl.app.dto;
import java.time.Instant;
public record AuthResponse(String serviceType, Instant expiresAt) {}
@@ -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) {}
@@ -0,0 +1,5 @@
package com.vaessl.app.dto;
import java.time.Instant;
public record LoginResult(Long connectionId, Instant expiresAt) {}