14 Commits

Author SHA1 Message Date
kasun f3fe9901c5 changed doc title 2026-05-20 04:43:51 +02:00
kasun c461aa81cc Merge pull request 'changed hibernate schema creation for test db' (#37) from enhancement/reorganize-databases into main
Reviewed-on: #37
2026-05-20 04:40:45 +02:00
kasun 856fa9e166 updated local postgresql database and updated doc for pgvector db creation 2026-05-20 04:39:29 +02:00
kasun 7ce01dff0b changed hibernate schema creation for test db 2026-05-20 04:38:46 +02:00
kasun 463fbd8332 added focus to first input field of modal 2026-05-13 17:33:08 +02:00
kasun 0cce4727e5 fixed indentation 2026-05-13 16:44:45 +02:00
kasun 83427b4f6b revsied documentation according to progress made 2026-05-12 20:23:39 +02:00
kasun 4aa3d0134c revised CLAUDE.md 2026-05-12 20:23:20 +02:00
kasun 0eb135249e fixed typos 2026-05-12 18:27:46 +02:00
kasun 1bada8d83e upgraded Spring Boot version from 4.0.5 to 4.0.6 2026-05-12 18:22:10 +02:00
kasun b55fcf19f2 Merge pull request 'add feature with claude code' (#34) from infrastructure/Evaluate-feasibility-of-a-paid-code-assistant into main
Reviewed-on: #34
2026-05-10 03:50:50 +02:00
kasun a8e39d8f09 changed cors config to use env variables 2026-05-10 03:49:41 +02:00
kasun 2c766b10a3 added doc for claude code implementation 2026-05-10 03:32:16 +02:00
kasun 43bbcece7a 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>
2026-05-10 03:30:36 +02:00
35 changed files with 1644 additions and 585 deletions
+1
View File
@@ -0,0 +1 @@
.vscode/
+34 -6
View File
@@ -4,11 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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. 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. The frontend has a working connection management dashboard.
## Commands ## Commands
### Backend (Spring Boot + Gradle, inside `backend/`) ### Backend (Spring Boot + Gradle, inside `backend/`)
```bash ```bash
./gradlew build # compile and package ./gradlew build # compile and package
./gradlew test # run all tests ./gradlew test # run all tests
@@ -16,6 +17,7 @@ Vaessl is an AI-powered integration bridge that accepts user text/image inputs,
``` ```
### Frontend (React + Vite, inside `frontend/`) ### Frontend (React + Vite, inside `frontend/`)
```bash ```bash
npm run dev # start dev server npm run dev # start dev server
npm run build # TypeScript check + Vite build npm run build # TypeScript check + Vite build
@@ -27,32 +29,58 @@ npm run test:ui # Vitest visual dashboard
## Environment ## Environment
Copy `.env.local` (not committed) into `backend/` with: Copy `.env.local` (not committed) into `backend/` with:
- `DB_URL`, `DB_TEST_URL`, `DB_USERNAME`, `DB_PASSWORD` — PostgreSQL (test container on port 5434) - `DB_URL`, `DB_TEST_URL`, `DB_USERNAME`, `DB_PASSWORD` — PostgreSQL (test container on port 5434)
- `PG_DRIVER_CLASS_NAME` — PostgreSQL JDBC driver class
- `OPENAI_KEY`, `OPENAI_BASE_URL` — LiteLLM gateway (provider-agnostic, configured for gpt-4o-mini) - `OPENAI_KEY`, `OPENAI_BASE_URL` — LiteLLM gateway (provider-agnostic, configured for gpt-4o-mini)
- `FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL` — allowed CORS origins for the backend
Frontend (optional, defaults to `/api`):
- `VITE_API_URL` — backend base URL used by `api/client.ts`
## Architecture ## Architecture
### Backend (`backend/src/main/java/com/vaessl/app/`) ### Backend (`backend/src/main/java/com/vaessl/app/`)
Three main modules: Server context path is `/api`. Main endpoints:
- `POST /api/login` — authenticates a service, stores connection ID in session
- `GET /api/connections/status` — lists connected services for the current session
- `DELETE /api/connections/{serviceType}` — removes a service from the session; invalidates the session if no connections remain
Four main modules:
**`config/`**
- `CorsConfig`: env-driven allowed origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`); `allowCredentials(true)` is required for session cookies to work cross-origin
- `SessionConfig`: JDBC-backed Spring Session with a persistent cookie (`SameSite=Lax`, `HttpOnly`)
**`connection/`** — core business logic **`connection/`** — core business logic
- `ConnectionProvider` interface: each integrated app (Homebox, WikiJS) implements `login()` and declares its `ServiceType` - `ConnectionProvider` interface: each integrated app (Homebox, WikiJS) implements `login()` and declares its `ServiceType`
- `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType` - `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType`
- `ConnectionController`: stores `{serviceType}_CONNECTION_ID` in `HttpSession` after login; reads session attributes to build status responses
- Entity (`Connection`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns - Entity (`Connection`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns
- DTOs use `Map<String, Object>` for flexible cross-app credential/result exchange
**`dto/`** — `ConnectionRequest` / `ConnectionResponse` **`dto/`** — `ConnectionRequest`, `LoginResult`, `AuthResponse`, `ConnectionStatusResponse`
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice` **`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
### Frontend (`frontend/src/`) ### Frontend (`frontend/src/`)
React 19 + TypeScript + Tailwind CSS, Vite 8 build. Currently basic scaffolding; no significant business logic yet. React 19 + TypeScript + SCSS, Vite 8 build.
- `api/client.ts` — typed `apiFetch` wrapper; always sends `credentials: 'include'` for session cookies; base URL from `VITE_API_URL` (defaults to `/api`)
- `api/connections.ts` — connection-specific API calls
- `types/connection.ts` — shared types: `LoginRequest`, `AuthResponse`, `ConnectionStatus`
- `components/Dashboard` — main view listing connected services
- `components/ConnectModal` — login form for adding a service connection
- `components/ServiceCard` — per-service status display
### Data & AI ### Data & AI
- PostgreSQL + pgvector (semantic search via embeddings) - PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it - 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 - Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
+8 -2
View File
@@ -1,6 +1,6 @@
plugins { plugins {
java java
id("org.springframework.boot") version "4.0.5" id("org.springframework.boot") version "4.0.6"
id("io.spring.dependency-management") version "1.1.7" id("io.spring.dependency-management") version "1.1.7"
} }
@@ -27,6 +27,7 @@ extra["springAiVersion"] = "2.0.0-M3"
dependencies { dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa") 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-security")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webmvc") 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-validation-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") 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 { dependencyManagement {
imports { imports {
@@ -48,6 +50,10 @@ dependencyManagement {
} }
} }
tasks.withType<JavaCompile> {
options.compilerArgs.add("-parameters")
}
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
} }
@@ -0,0 +1,25 @@
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-local-url}")
private String frontendLocalUrl;
@Value("${vaessl.frontend-public-url}")
private String frontendPublicUrl;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(frontendLocalUrl, frontendPublicUrl)
.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; 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.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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import com.vaessl.app.dto.AuthResponse;
import com.vaessl.app.dto.ConnectionRequest; 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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -15,10 +28,63 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ConnectionController { public class ConnectionController {
private static final String SUFFIX = "_CONNECTION_ID";
private final ConnectionService connectionService; private final ConnectionService connectionService;
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<ConnectionResponse> loginResponse(@Valid @RequestBody ConnectionRequest request) { public ResponseEntity<AuthResponse> login(
return ResponseEntity.ok(connectionService.login(request)); @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; package com.vaessl.app.connection;
import java.time.Instant;
import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse; import com.vaessl.app.dto.ConnectionResponse;
@@ -16,4 +18,8 @@ public interface ConnectionProvider {
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response); void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
default Instant getTokenExpiry(ConnectionEntity entity) {
return null;
}
} }
@@ -1,5 +1,6 @@
package com.vaessl.app.connection; package com.vaessl.app.connection;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; 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.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse; import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.dto.ConnectionStatusResponse;
import com.vaessl.app.dto.LoginResult;
import com.vaessl.app.exception.WrongServiceTypeException; import com.vaessl.app.exception.WrongServiceTypeException;
@Service @Service
@@ -23,7 +26,7 @@ public class ConnectionService {
this.cRepository = cRepository; this.cRepository = cRepository;
} }
public ConnectionResponse login(ConnectionRequest request) { public LoginResult login(ConnectionRequest request) {
ConnectionProvider provider = providerRegistry.get(request.serviceType()); ConnectionProvider provider = providerRegistry.get(request.serviceType());
@@ -37,13 +40,27 @@ public class ConnectionService {
ConnectionEntity existing = provider.findUniqueConnectionEntry(request); ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
ConnectionEntity saved;
if (existing != null) { if (existing != null) {
provider.updateToRepository(existing, response); provider.updateToRepository(existing, response);
saved = existing;
} else { } else {
ConnectionEntity newEntity = provider.connectionToEntity(request, response); 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; package com.vaessl.app.connection;
public enum Endpoint { 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; 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) { 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) {}
@@ -0,0 +1,17 @@
{"properties": [
{
"name": "spring.session.store-type",
"type": "java.lang.String",
"description": "A description for 'spring.session.store-type'"
},
{
"name": "vaessl.frontend-local-url",
"type": "java.lang.String",
"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'"
}
]}
+8 -1
View File
@@ -7,7 +7,7 @@ spring:
- "optional:file:backend/.env.local[.properties]" - "optional:file:backend/.env.local[.properties]"
- "optional:file:vaessl/backend/.env.local[.properties]" - "optional:file:vaessl/backend/.env.local[.properties]"
datasource: datasource:
url : ${DB_URL} url: ${DB_URL}
username: ${DB_USERNAME} username: ${DB_USERNAME}
password: ${DB_PASSWORD} password: ${DB_PASSWORD}
driver-class-name: ${PG_DRIVER_CLASS_NAME} driver-class-name: ${PG_DRIVER_CLASS_NAME}
@@ -22,6 +22,13 @@ spring:
chat: chat:
options: options:
model: gpt-4o-mini model: gpt-4o-mini
session:
store-type: jdbc
jdbc:
initialize-schema: always
server: server:
servlet: servlet:
context-path: /api context-path: /api
vaessl:
frontend-local-url: ${FRONTEND_LOCAL_URL}
frontend-public-url: ${FRONTEND_PUBLIC_URL}
@@ -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);
}
}
@@ -8,6 +8,7 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest; 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_USER = "admin";
private static final String TEST_PASS = "pw"; private static final String TEST_PASS = "pw";
/** /**
* Returns Token and status code OK when login is successful. * Returns Token and status code OK when login is successful.
* *
* @param wm * @param wm the WiremockRuntimeInfo object
* the WiremockRuntimeInfo object
*/ */
@Test @Test
void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue()) stubFor(post(HOMEBOX_LOGIN.getValue())
.willReturn(okJson(okJsonHomeboxResponse))); .willReturn(okJson(okJsonHomeboxResponse)));
@@ -62,11 +61,8 @@ class HomeboxIntegrationTest {
DocumentContext documentContext = JsonPath.parse(response.getBody()); DocumentContext documentContext = JsonPath.parse(response.getBody());
String token = documentContext.read("$.token"); String serviceType = documentContext.read("$.serviceType");
assertThat(token).isEqualTo("fake-jwt-token"); assertThat(serviceType).isEqualTo("HOMEBOX");
String attachmentToken = documentContext.read("$.extraResponseData.attachmentToken");
assertThat(attachmentToken).isEqualTo("fake-attach");
String expiresAt = documentContext.read("$.expiresAt", String.class); String expiresAt = documentContext.read("$.expiresAt", String.class);
assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z"); assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z");
@@ -76,8 +72,7 @@ class HomeboxIntegrationTest {
/** /**
* Tests the Unauthorized custom exception. * Tests the Unauthorized custom exception.
* *
* @param wm * @param wm the WiremockRuntimeInfo object
* the WiremockRuntimeInfo object
*/ */
@Test @Test
void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) {
@@ -94,8 +89,7 @@ class HomeboxIntegrationTest {
/** /**
* Tests a server error from the external api. * Tests a server error from the external api.
* *
* @param wm * @param wm the WiremockRuntimeInfo object
* the WiremockRuntimeInfo object
*/ */
@Test @Test
void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) {
@@ -113,14 +107,12 @@ class HomeboxIntegrationTest {
* Checks when the service is unavailable or the app URL is wrong. * Checks when the service is unavailable or the app URL is wrong.
*/ */
@Test @Test
void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong() { void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) {
ConnectionRequest badRequest = new ConnectionRequest( stubFor(post(HOMEBOX_LOGIN.getValue())
MOCK_URL, .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
HOMEBOX.getValue(),
TEST_USER, TEST_PASS,
false);
ConnectionRequest badRequest = connectionRequest(wm);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
@@ -146,9 +138,9 @@ class HomeboxIntegrationTest {
* unsupported. * unsupported.
*/ */
@Test @Test
void shouldReturnWrongServiceTypeException() { void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) {
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(
MOCK_URL, wm.getHttpBaseUrl(),
"wrong-service-type", "wrong-service-type",
TEST_USER, TEST_PASS, TEST_USER, TEST_PASS,
false); false);
@@ -164,8 +156,7 @@ class HomeboxIntegrationTest {
* Tests the succesfull persistance of Homebox credential response to the * Tests the succesfull persistance of Homebox credential response to the
* database. * database.
* *
* @param wm * @param wm the WiremockRuntimeInfo object
* the WiremockRuntimeInfo object
*/ */
@Test @Test
void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) { void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) {
@@ -176,26 +167,25 @@ class HomeboxIntegrationTest {
ConnectionRequest request = connectionRequest(wm); ConnectionRequest request = connectionRequest(wm);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class); ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
DocumentContext responseContext = JsonPath.parse(response.getBody());
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
assertThat(dbEntry).isNotNull(); assertThat(dbEntry).isNotNull();
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
assertThat(dbEntry.getUsername()).isEqualTo(request.username()); assertThat(dbEntry.getUsername()).isEqualTo(request.username());
if (dbEntry instanceof HomeboxEntity hbE) { if (dbEntry instanceof HomeboxEntity hbE) {
assertThat(hbE.getToken()).isEqualTo(responseContext.read("$.token")); assertThat(hbE.getToken()).isEqualTo("fake-jwt-token");
assertThat(hbE.getAttachmentToken()).isEqualTo(responseContext.read("$.extraResponseData.attachmentToken")); assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach");
assertThat(hbE.getExpiresAt()).isEqualTo(responseContext.read("$.expiresAt")); assertThat(hbE.getExpiresAt().toString()).hasToString("2026-04-26T02:23:13Z");
} }
} }
@Test @Test
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing() { void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
ConnectionRequest missingCredentials = new ConnectionRequest(MOCK_URL, HOMEBOX.getValue(), TEST_USER, null, ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER,
null,
false); false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, ResponseEntity<String> 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. * WireMockRuntimeInfo.
* *
* @param wm * @param wm the WiremockRuntimeInfo object
* the WiremockRuntimeInfo object
* @return a mock api connection request. * @return a mock api connection request.
*/ */
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
@@ -1,9 +1,9 @@
spring: spring:
datasource: datasource:
url : ${DB_TEST_URL} url: ${DB_TEST_URL}
username: ${DB_USERNAME} username: ${DB_USERNAME}
password: ${DB_PASSWORD} password: ${DB_PASSWORD}
driver-class-name: ${PG_DRIVER_CLASS_NAME} driver-class-name: ${PG_DRIVER_CLASS_NAME}
jpa: jpa:
hibernate: hibernate:
ddl-auto: create-drop ddl-auto: update
@@ -1,4 +1,4 @@
**Vaessl: Spring Boot setup** **Vaessl: Spring Boot and database setup**
This app will use the current latest version 4.0.4 of Spring Boot and the latest OpenJDK 25 LTS. This app will use the current latest version 4.0.4 of Spring Boot and the latest OpenJDK 25 LTS.
@@ -138,7 +138,7 @@ spring:
``` ```
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. The Docker Compose file will look something like this: The Docker Compose file for code-server will look something like this:
``` ```
--- ---
@@ -163,26 +163,43 @@ services:
- 8124:8080 - 8124:8080
- 5173:5173 - 5173:5173
restart: unless-stopped restart: unless-stopped
```
vaessl-db: Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. Just add databases via SQL or PgAdmin and install the pgvector extension to each database manually. There is an offical ready-made pgvector docker image but if you already host a PostGreSQL database you need to add the extension yourself.
image: pgvector/pgvector:pg18
container_name: vaessl-db
environment:
- POSTGRES_DB=vaessl
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pw
ports:
- 5433:5432
vassal-test-db: Check the name of your PostGreSQL container:
image: pgvector/pgvector:pg18 ```
container_name: vassal-test-db docker ps
environment: ```
- POSTGRES_DB=vassal_test
- POSTGRES_USER=user Enter your container via bash:
- POSTGRES_PASSWORD=pw
ports: ```
- 5434:5432 docker exec -it 876fb382969f bash
```
Before working on your database backup your databases:
```
su - postgres -c "pg_dumpall > /tmp/backup200526.sql"
#exit the container and copy the backup file to local file system
docker cp 876fb382969f:/tmp/backup200526.sql .
```
Install dependencies, build and install pgvector:
apt-get update
apt-get install -y build-essential git postgresql-server-dev-all
```
git clone https://github.com/pgvector/pgvector.git
cd pgvector
make
make install
docker restart 876fb382969f
```
Enter PostGreSQL container and create pgvector extension for each databse:
```
docker exec -it <container-name> psql -h localhost -U <db-user> -d <db-name>
CREATE EXTENSION vector;
``` ```
# Appendix: Additional config for developing in Code-Server # Appendix: Additional config for developing in Code-Server
@@ -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 components. 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<T> 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 <Dashboard /> 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
+80 -205
View File
@@ -1,168 +1,121 @@
**vaessl: Login Architecture** **vaessl: Login Architecture**
# Backend # Backend
The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance will be used as good as possible to keep refactorings to a minimum. The first app to bridge will be Homebox which uses a classic username, password and Bearer token login proces to authenticate calls to its api. The second hypothetic app will be WikiJs which simply uses a user generated api key. The abstraction of the Java classes will try to cater to both authentication methods.
The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance are used to keep refactorings to a minimum. The first app to bridge is Homebox, which uses a classic username/password and Bearer token login process. The second hypothetical app, WikiJS, uses a user-generated API key. The abstractions cater to both authentication methods.
## API Endpoints
The server context path is `/api`. The three endpoints that drive the connection flow:
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/login` | Authenticate a service; stores connection ID in session |
| `GET` | `/api/connections/status` | List connected services for the current session |
| `DELETE` | `/api/connections/{serviceType}` | Remove a service from the session; invalidates the session if no connections remain |
## Session Management
Authentication state is held server-side in a JDBC-backed Spring Session (stored in the same PostgreSQL database). After a successful login the controller stores `{serviceType}_CONNECTION_ID` in the `HttpSession`. The session cookie is `HttpOnly`, `SameSite=Lax`, and set to max age.
`CorsConfig` allows credentials from the two configured origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`). `allowCredentials(true)` is required for the session cookie to travel cross-origin.
## Single Table Inheritance ## Single Table Inheritance
The database entities will follow the Single Table Inheritance concept.
The database will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field. Database entities use Single Table Inheritance — one `connections` table with a `service_type` discriminator column and all app-specific nullable columns. A unique constraint on `(appUrl, username)` prevents duplicate entries per user per instance.
The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy:
***ConnectionEntitiy.java*** ***ConnectionEntity.java***
```
package com.vaessl.app.connection;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
```java
@Entity @Entity
@Table(name = "connections") @Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type") @DiscriminatorColumn(name = "service_type")
@Getter
@Setter
@NoArgsConstructor
public abstract class ConnectionEntity { public abstract class ConnectionEntity {
@Id @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
Long id;
private String appUrl; private String appUrl;
private String username;
} }
``` ```
***HomeboxEntitiy.java*** ***HomeboxEntity.java***
```
package com.vaessl.app.connection;
import java.time.Instant;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
```java
@Entity @Entity
@DiscriminatorValue("HOMEBOX") @DiscriminatorValue("HOMEBOX")
@Getter
@Setter
public class HomeboxEntity extends ConnectionEntity { public class HomeboxEntity extends ConnectionEntity {
private String token; private String token;
private String attachmentToken; private String attachmentToken;
private Instant expiresAt; private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity(); HomeboxEntity he = new HomeboxEntity();
he.setAppUrl(request.appUrl()); he.setAppUrl(request.appUrl());
he.setUsername(request.username());
he.setToken(response.token()); he.setToken(response.token());
he.setAttachmentToken(response.getExtraVar("attachmentToken")); he.setAttachmentToken(response.getExtraVar("attachmentToken"));
he.setExpiresAt(response.expiresAt()); he.setExpiresAt(response.expiresAt());
return he; return he;
} }
} }
``` ```
## The Provider Pattern (Logic Layer) ## The Provider Pattern (Logic Layer)
To keep the core business logic clean, the app uses a Provider Pattern. This separates how the app authenticates (the Specific) from what the system does with that info (the General).
- ConnectionProvider Interface: Defines the contract. Every new app (Homebox, WikiJS, etc.) must implement this interface to tell the system how to authenticate and how to map its specific API response into a database entity. Each integrated app implements `ConnectionProvider`. This separates how an app authenticates (the specific) from what the system does with that result (the general).
***ConnectionProvider.java*** ***ConnectionProvider.java***
```
package com.vaessl.app.connection;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
```java
public interface ConnectionProvider { public interface ConnectionProvider {
String getServiceType(); String getServiceType();
void checkCredentials(ConnectionRequest request);
ConnectionResponse authenticate(ConnectionRequest request); ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response); void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
default Instant getTokenExpiry(ConnectionEntity entity) {
return null;
}
} }
``` ```
***HomeboxConnectionProvider.java***
```
package com.vaessl.app.connection;
import java.time.Instant; - `checkCredentials` — validates that required fields are present before attempting the remote call (throws `EmptyCredentialsException` with a list of missing fields)
import java.util.HashMap; - `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update
import java.util.Map; - `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag
import org.springframework.stereotype.Component; ***HomeBoxConnectionProvider.java***
import org.springframework.web.client.RestClient;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import static com.vaessl.app.connection.Endpoint.*;
```java
@Component @Component
public class HomeBoxConnectionProvider implements ConnectionProvider { public class HomeBoxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder; @Override
public String getServiceType() { return "HOMEBOX"; }
private final ConnectionRepository cRepository;
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override @Override
public String getServiceType() { public void checkCredentials(ConnectionRequest request) {
return "HOMEBOX"; // throws EmptyCredentialsException if username or password is null
} }
@Override @Override
public ConnectionResponse authenticate(ConnectionRequest request) { public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", request.credentials().get("username"), // POST to Homebox /api/v1/users/login, returns token + attachmentToken + expiresAt
"password", request.credentials().get("password"), "stayLoggedIn", }
request.stayLoggedIn());
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) @Override
.build() public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
.post() return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
.uri(HOMEBOX_LOGIN.getValue())
.body(homeboxPayload)
.retrieve()
.body(HomeboxLoginResponse.class);
if (hbResponse == null) {
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
}
Map<String, Object> attachmentToken = new HashMap<>();
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
} }
@Override @Override
@@ -172,131 +125,53 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
@Override @Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) { public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
// casts to HomeboxEntity and updates token, attachmentToken, expiresAt
if (existing instanceof HomeboxEntity hbE) {
hbE.setToken(response.token());
hbE.setExpiresAt(response.expiresAt());
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
cRepository.save(hbE);
}
} }
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { @Override
public Instant getTokenExpiry(ConnectionEntity entity) {
return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null;
} }
} }
``` ```
- Provider Registry: The ConnectionService automatically detects all implementations of the provider interface and stores them in a map. When a login request comes in, the service simply looks up the correct provider by its "Service Type" string.
***ConnectionService.java*** ***ConnectionService.java***
```
package com.vaessl.app.connection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.ProviderNotFoundException;
```java
@Service @Service
public class ConnectionService { public class ConnectionService {
private final Map<String, ConnectionProvider> providerRegistry; // auto-discovers all ConnectionProvider beans into a serviceType → provider map
private final ConnectionRepository cRepository;
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
this.providerRegistry = providers.stream()
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
this.cRepository = cRepository;
}
public ConnectionResponse login(ConnectionRequest request) {
ConnectionProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new ProviderNotFoundException();
}
public LoginResult login(ConnectionRequest request) {
provider.checkCredentials(request);
ConnectionResponse response = provider.authenticate(request); ConnectionResponse response = provider.authenticate(request);
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
// insert or update, then return LoginResult(connectionId, expiresAt)
}
ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl()); public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
// looks up entity by id, calls provider.getTokenExpiry() to compute connected flag
if (existing != null) {
provider.updateToRepository(existing, response);
} else {
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
cRepository.save(newEntity);
}
return response;
} }
} }
``` ```
## Generic Data Exchange (The DTOs) ## DTOs
Since different apps return different types of data (e.g., Homebox returns an attachmentToken, but WikiJS might return a something else), I use a Generic Data Bridge to move information between the API and the Database.
- ConnectionRequest: A universal "envelope" containing common fields (appUrl, serviceType) and a flexible Map<String, String> for credentials. This allows one DTO to handle both username/password logins and API-key-only logins. ### Request / Internal
***ConnectionRequest.java*** - **`ConnectionRequest`** — fields: `appUrl`, `serviceType`, `username`, `password`, `apiKey`, `stayLoggedIn`. Typed fields (not a credentials map) let providers validate exactly what they need via `checkCredentials()`. `apiKey` covers future API-key-only providers (e.g. WikiJS).
``` - **`ConnectionResponse`** — internal DTO between provider and service: `token`, `expiresAt`, `Map<String, Object> extraResponseData`. The `getExtraVar(key)` helper safely extracts app-specific values (e.g. Homebox's `attachmentToken`).
package com.vaessl.app.dto;
import java.util.Map; ### Response (returned to frontend)
import com.fasterxml.jackson.annotation.JsonProperty; - **`LoginResult`** — `connectionId`, `expiresAt`. Returned by `ConnectionService.login()` and used by the controller to populate the session.
import jakarta.validation.constraints.NotBlank; - **`AuthResponse`** — `serviceType`, `expiresAt`. Returned by `POST /api/login` to the frontend.
import jakarta.validation.constraints.NotEmpty;
public record ConnectionRequest( - **`ConnectionStatusResponse`** — `serviceType`, `appUrl`, `username`, `expiresAt`, `connected`. Returned by `GET /api/connections/status`.
@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType,
@NotEmpty(message = "Credentials are mandatory") Map<String, String> credentials,
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest { ## Supported Service Types
if (stayLoggedIn == null) {
stayLoggedIn = false;
}
}
}
```
- ConnectionResponse: A "Smart" DTO that holds the core authentication data (the token and expiresAt) and a Map<String, Object> called extraResponseData. Registered in the `ServiceType` enum. Currently: `HOMEBOX`. Adding a new service means implementing `ConnectionProvider` and adding the discriminator value — no changes to `ConnectionService` or `ConnectionController` required.
- The "Smart" Getter: The response object contains helper methods to safely extract app-specific variables from this map. This allows the system to be "Generic" while still giving specific entities (like HomeboxEntity) access to the unique data they need.
***ConnectionResponse.java***
```
package com.vaessl.app.dto;
import java.time.Instant;
import java.util.Map;
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
public String getExtraVar(String key) {
if(extraResponseData == null) {
return null;
} else {
Object value = extraResponseData.get(key);
return value != null ? String.valueOf(value) : null;
}
}
}
```
+395
View File
@@ -25,6 +25,7 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0", "globals": "^17.4.0",
"jsdom": "^29.0.1", "jsdom": "^29.0.1",
"sass": "^1.99.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",
"vite": "^8.0.1", "vite": "^8.0.1",
@@ -840,6 +841,334 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "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" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2559,6 +2904,13 @@
"node": ">= 4" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3163,6 +3515,14 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -3412,6 +3772,20 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -3487,6 +3861,27 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/saxes": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+1
View File
@@ -29,6 +29,7 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0", "globals": "^17.4.0",
"jsdom": "^29.0.1", "jsdom": "^29.0.1",
"sass": "^1.99.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",
"vite": "^8.0.1", "vite": "^8.0.1",
-184
View File
@@ -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);
}
}
+3 -114
View File
@@ -1,120 +1,9 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg' import { Dashboard } from './components/Dashboard'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
function App() { function App() {
const [count, setCount] = useState(0)
return ( return (
<> <Dashboard/>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
) )
} }
+21
View File
@@ -0,0 +1,21 @@
const BASE = import.meta.env.VITE_API_URL ?? '/api'
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
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()
}
+11
View File
@@ -0,0 +1,11 @@
import { apiFetch } from './client'
import type { AuthResponse, ConnectionStatus, LoginRequest } from '../types/connection'
export const login = (req: LoginRequest) =>
apiFetch<AuthResponse>('/login', { method: 'POST', body: JSON.stringify(req) })
export const getStatuses = () =>
apiFetch<ConnectionStatus[]>('/connections/status')
export const logout = (serviceType: string) =>
apiFetch<void>(`/connections/${serviceType}`, { method: 'DELETE' })
+113
View File
@@ -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; }
}
}
+85
View File
@@ -0,0 +1,85 @@
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<Props>) {
const [appUrl, setAppUrl] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const firstInputRef = useRef<HTMLInputElement>(null)
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
dialog.showModal()
firstInputRef.current?.focus()
const handleCancel = (e: Event) => {
e.preventDefault()
onClose()
}
dialog.addEventListener('cancel', handleCancel)
return () => dialog.removeEventListener('cancel', handleCancel)
}, [onClose])
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
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 (
<dialog className="modal" ref={dialogRef}>
<div className="modal__header">
<h2 className="modal__title" id="modal-title">Connect to {label}</h2>
<button className="modal__close" onClick={onClose} aria-label="Close">×</button>
</div>
<form className="modal__form" onSubmit={handleSubmit}>
<div className="modal__field">
<label className="modal__label" htmlFor="appUrl">App URL</label>
<input id="appUrl" ref={firstInputRef} className="modal__input" type="url"
placeholder="https://homebox.example.com"
value={appUrl} onChange={e => setAppUrl(e.target.value)} required />
</div>
<div className="modal__field">
<label className="modal__label" htmlFor="username">Username</label>
<input id="username" className="modal__input" type="text"
autoComplete="username"
value={username} onChange={e => setUsername(e.target.value)} required />
</div>
<div className="modal__field">
<label className="modal__label" htmlFor="password">Password</label>
<input id="password" className="modal__input" type="password"
autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)} required />
</div>
{error && <p className="modal__error">{error}</p>}
<button className="modal__submit" type="submit" disabled={loading}>
{loading ? 'Connecting…' : 'Connect'}
</button>
</form>
</dialog>
)
}
+40
View File
@@ -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;
}
}
+59
View File
@@ -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<ConnectionStatus[]>([])
const [openModal, setOpenModal] = useState<string | null>(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 (
<div className="dashboard">
<div className="dashboard__header">
<h1 className="dashboard__title">Vaessl Dashboard</h1>
</div>
<p className="dashboard__section-label">Services</p>
<div className="dashboard__cards">
{SERVICES.map(({ serviceType, label, icon }) => (
<ServiceCard
key={serviceType}
serviceType={serviceType}
label={label}
icon={icon}
status={statuses.find(s => s.serviceType === serviceType) ?? null}
onConnect={() => setOpenModal(serviceType)}
onDisconnect={() => handleDisconnect(serviceType)}
/>
))}
</div>
{activeModal && (
<ConnectModal
serviceType={activeModal.serviceType}
label={activeModal.label}
onClose={() => setOpenModal(null)}
onSuccess={() => { setOpenModal(null); refresh() }}
/>
)}
</div>
)
}
+119
View File
@@ -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;
}
}
}
}
+52
View File
@@ -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<Props>) {
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 (
<div className="service-card">
<div className="service-card__info">
<div className="service-card__icon">{icon}</div>
<div>
<p className="service-card__name">{label}</p>
<p className="service-card__meta">
<span className={`service-card__badge service-card__badge--${connected ? 'connected' : 'disconnected'}`}>
{connected ? 'Connected' : 'Not connected'}
</span>
{connected && status?.username && <span>{status.username}</span>}
{connected && status?.expiresAt && (
<span>· expires {formatExpiry(status.expiresAt)}</span>
)}
</p>
</div>
</div>
<div className="service-card__actions">
{connected ? (
<button className="service-card__btn service-card__btn--disconnect" onClick={onDisconnect}>
Disconnect
</button>
) : (
<button className="service-card__btn service-card__btn--connect" onClick={onConnect}>
Connect
</button>
)}
</div>
</div>
)
}
+20
View File
@@ -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
}
+18 -9
View File
@@ -1,12 +1,21 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// https://vite.dev/config/ export default defineConfig(({ mode }) => {
export default defineConfig({ const env = loadEnv(mode, process.cwd(), '');
plugins: [react()],
server: { return {
host: '0.0.0.0', plugins: [react()],
port: 5173, server: {
allowedHosts: ['5173.code-server.kasuns.website'], host: '0.0.0.0',
}, port: 5173,
allowedHosts: env.FRONTEND_URL ? [env.FRONTEND_URL] : [],
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
}
}) })