65 Commits

Author SHA1 Message Date
kasun 1a86c23565 corrected javadoc 2026-05-21 20:42:14 +02:00
kasun ea866377bc test: add unit tests for HomeboxSearchProvider and SearchResponse
HomeboxSearchProviderTest verifies that ConnectionNotFoundException is
thrown when no matching connection exists in the repository.
SearchResponseTest covers the getExtra helper — null extra map, missing
key, and a present key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:36:31 +02:00
kasun a7b984ca84 refactor: consolidate test mock constants into shared Mockdata class
Moved Mockdata from the connection package to the root test package and
extended it with MOCK_USER, MOCK_PASS, MOCK_TITLE, and MOCK_DESCRIPTION
so all test modules share a single source of truth. Removed duplicate
inline constants from ConnectionControllerTest, HomeboxIntegrationTest,
and SearchControllerTest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:36:22 +02:00
kasun 1ba85e129e added SearchControllerTest 2026-05-18 03:14:38 +02:00
kasun c75bf2ad71 changed hibernate schema creation 2026-05-17 19:52:40 +02:00
kasun d91f39d087 implemented basic search function with Homebox provider 2026-05-17 04:22:29 +02:00
kasun f39bf049a0 added RemoteApiException 2026-05-17 00:38:47 +02:00
kasun 5b2648d526 changed formatter 2026-05-17 00:31:10 +02:00
kasun a8ecf65180 changed formatter 2026-05-17 00:26:46 +02:00
kasun 4d96524adb reoargnized packages 2026-05-16 02:38:33 +02:00
kasun 1d5006fd7e reorganized packages 2026-05-16 01:48:36 +02:00
kasun 406a041ce9 reorganized packages 2026-05-16 01:47:12 +02:00
kasun d7233d817c reorganized packages 2026-05-16 01:14:34 +02:00
kasun 92aaf63c12 fixed Homebox typo 2026-05-16 00:16:28 +02:00
kasun ef09a3c84d revised CORS config for lalowed origins 2026-05-14 14:57:25 +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
kasun 0127706262 added doc for adding claude code to path 2026-04-22 19:03:06 +02:00
kasun 2cbb8b7467 add CLAUDE.md with architecture and development guidance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:58:12 +02:00
kasun 4a256c8086 added stack illustration to Readme.md 2026-04-11 21:51:41 +02:00
kasun ae3e699938 Merge branch 'doc/Update-the-cover-Readme.md' 2026-04-11 14:55:17 +02:00
kasun 7b21011f5a updated README.MD 2026-04-11 14:49:22 +02:00
kasun 750dda897d Merge pull request 'feature/implement-external-login-api' (#30) from feature/implement-external-login-api into main
Reviewed-on: #30
2026-04-09 21:21:58 +02:00
kasun 01fa3e4267 added integration test for missing creds 2026-04-09 21:18:52 +02:00
kasun a1ebb1be21 format 2026-04-09 21:18:40 +02:00
kasun 7de38afec2 added ConnectionService and HomeboxProvider tests 2026-04-09 21:11:17 +02:00
kasun a5c1931a2a removed apikey variable from homebox test 2026-04-09 21:09:27 +02:00
kasun 3e80b81e07 added comment 2026-04-08 22:35:59 +02:00
kasun ef4ee70aac added WrongServiceTypeException 2026-04-08 22:33:04 +02:00
kasun 00bb929f22 format 2026-04-08 22:32:06 +02:00
kasun c553735653 removed main test 2026-04-08 22:31:36 +02:00
kasun 680e5f0abd revised exception handling for empty fields. 2026-04-08 20:57:27 +02:00
kasun 2000278f1a got rid if Map and added username, password and apikey to request body 2026-04-08 20:51:14 +02:00
kasun 702e6bb973 revised HBTest to match new request body 2026-04-08 20:49:28 +02:00
kasun 8a5f7c2bf8 added username to ConnectionEntity 2026-04-08 20:48:21 +02:00
kasun 3e4a1f92b1 added username for uniqueness of connection db entry 2026-04-08 17:38:42 +02:00
kasun c59f2598b0 added final fileds 2026-04-08 02:22:07 +02:00
kasun 30784fa756 format 2026-04-08 02:21:55 +02:00
kasun da0411f5d1 corrected typo 2026-04-06 11:51:03 +02:00
kasun 240a366ce8 added login logic excl refresh call 2026-04-06 05:02:46 +02:00
kasun 9c3e1469c7 added ApplicationTests 2026-04-06 03:51:36 +02:00
kasun ba7887f6b2 updated spring boot version 2026-04-06 02:09:58 +02:00
kasun 8b1a604dc2 renamed enum classes 2026-04-06 01:28:31 +02:00
kasun be0821b0be replaced constructor with requiredArgsConstructor annotation 2026-04-04 23:15:38 +02:00
kasun 913f3c75f1 made improvements 2026-04-03 21:28:00 +02:00
kasun 0169cf04b6 refactored connection classes to be more generic and accept credentials of different apps. 2026-04-03 02:58:34 +02:00
kasun ab1d7e68f5 added rest path 2026-04-02 20:28:34 +02:00
kasun 2387d41ebb changed format 2026-03-30 22:43:07 +02:00
kasun c15c7a0d61 added ErrorMessages enum 2026-03-30 22:41:41 +02:00
kasun bda9391c75 changed format 2026-03-30 21:30:19 +02:00
kasun 6267e18478 changed format 2026-03-30 20:42:48 +02:00
kasun 79379b238a changes test name added comment 2026-03-30 20:42:39 +02:00
kasun 75b6995b94 added post request to achieve login response with tokens 2026-03-30 05:07:51 +02:00
kasun 8128ab829f added additional .env.local file import pathes 2026-03-30 05:05:57 +02:00
kasun 8da3b14e40 added wiremock dependency for remote api testing 2026-03-30 05:05:24 +02:00
kasun 55508a27bb Merge pull request 'added api client setup doc' (#28) from setup-Hoppscotch-instance into main
Reviewed-on: #28
2026-03-27 13:21:22 +01:00
kasun 892cbdacd2 added api client setup doc 2026-03-27 13:21:01 +01:00
65 changed files with 3078 additions and 324 deletions
+1
View File
@@ -0,0 +1 @@
.vscode/
+89
View File
@@ -0,0 +1,89 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Vaessl is an AI-powered integration bridge that accepts user text/image inputs, processes them through an LLM pipeline (via LiteLLM), and exports structured data to management systems (Homebox, WikiJS). The backend uses a provider pattern for extensibility. The frontend has a working connection management dashboard.
## Commands
### Backend (Spring Boot + Gradle, inside `backend/`)
```bash
./gradlew build # compile and package
./gradlew test # run all tests
./gradlew test --tests com.vaessl.app.connection.ConnectionServiceTest # single test class
```
### Frontend (React + Vite, inside `frontend/`)
```bash
npm run dev # start dev server
npm run build # TypeScript check + Vite build
npm run lint # ESLint
npm run test # Vitest watch mode
npm run test:ui # Vitest visual dashboard
```
## Environment
Copy `.env.local` (not committed) into `backend/` with:
- `DB_URL`, `DB_TEST_URL`, `DB_USERNAME`, `DB_PASSWORD` — PostgreSQL (test container on port 5434)
- `PG_DRIVER_CLASS_NAME` — PostgreSQL JDBC driver class
- `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
### Backend (`backend/src/main/java/com/vaessl/app/`)
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
- `ConnectionProvider` interface: each integrated app (Homebox, WikiJS) implements `login()` and declares its `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
**`dto/`** — `ConnectionRequest`, `LoginResult`, `AuthResponse`, `ConnectionStatusResponse`
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
### Frontend (`frontend/src/`)
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
- 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
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
### Testing Strategy
Integration tests spin up a **mirrored PostgreSQL container** on port 5434 (same schema as production). WireMock mocks external HTTP APIs (Homebox, WikiJS). Do not mock the database in integration tests — the mirrored container strategy exists specifically to catch schema/migration divergence.
+89
View File
@@ -0,0 +1,89 @@
## Project Intent
Vaessl is a **technical playground** and proof-of-concept designed to showcase a complete, professional-grade software lifecycle. The primary goal is not just to build a functional utility, but to demonstrate a multi-experience approach to full-stack engineering, DevOps, and AI integration.
This project serves as a transparent portfolio of my take on:
* **Infrastructure:** Moving from local-machine to browser-based development environment hosted on my Proxmox server.
* **Modern AI Orchestration:** Building a system that actually uses AI to do heavy lifting, like identifying objects and understanding natural language using Spring AI and LiteLLM.
* **Architectural Foresight:** Designing with abstraction layers early to ensure the system can scale from a single-target tool to a multi-app bridge without significant refactoring.
---
## What is Vaessl?
Vaessl acts as translator between user inputs via text or image and digital management systems. It performs image and semantic search analysis before serving results from applications (e.g., Homebox, WikiJS) via REST API.
### Core Functionality
* **Staging:** Raw images and metadata are stored in a PostgreSQL staging table.
* **AI-Powered Analysis:** The system utilizes **LiteLLM** as a unified gateway to process inputs via LLM of choice.
* **Semantic Discovery:** By utilizing **Spring AI** and **pgvector**, Vaessl stores description embeddings. This allows for intent-based search (e.g., finding a "Wrench" by searching for "tool to fix a leaky pipe").
---
## Technical Stack
| Layer | Technology |
| :--- | :--- |
| **Backend** | Spring Boot 4.0.x, Java 25 (LTS), Spring AI |
| **Frontend** | React (Vite), Tailwind CSS, SCSS |
| **Database** | PostgreSQL + `pgvector` |
| **AI Gateway** | LiteLLM (Unified API proxy) |
| **DevOps** | Docker, Portainer, Hoppscotch (API testing), Gitea Actions, Jenkins |
&nbsp;
![Vaessl stack illustration](assets/images/vaessl-stack-illustration.png)
---
## Infrastructure & Methodology
### 1. Centralized Development (Code-Server)
To maintain a zero-footprint local setup, I develop within a custom **code-server** Docker instance.
* **Custom Image:** The environment is baked with a specific toolchain (OpenJDK 25, Node.js 24) to ensure absolute environment parity.
* **Remote Access:** Secured via Pangolin tunnel, allowing for secure, remote development without exposing ports to the public internet.
### 2. Abstraction & Scalability
A core requirement was to prevent vendor or application lock-in:
* **API Abstraction:** While the initial integration targets **Homebox**, the export logic is decoupled. This allows the bridge to support other platforms like **WikiJS** by simply implementing new mapping profiles.
* **AI Agnostic:** LiteLLM abstracts the AI provider, allowing the backend to switch between cloud APIs and local inference engines (Ollama) without code changes.
### 3. Testing Strategy
* **Database Parity:** I utilize mirrored containers for development and testing (`vaessl-db` vs `vassal-test-db`). This ensures that JPA operations are tested against the real PostgreSQL engine and `pgvector` extensions rather than mocks.
* **Frontend TDD:** A Vitest-based stack provides a fast feedback loop for the UI, ensuring component reliability before integration with the Spring Boot backend.
---
## Roadmap
### Phase 1: Foundation
* [x] Deployment of core infrastructure (PostgreSQL, LiteLLM, Hoppscotch via Docker).
* [x] Custom Code-Server image build and deployment.
* [x] Spring Boot skeleton with Java 25 and Gradle Kotlin DSL.
### Phase 2: The Processing Pipeline (Current)
* [ ] Implementation of Image/Semantic Search $\rightarrow$ AI $\rightarrow$ Staging Table workflow.
* [ ] Development of the data refinement UI in React.
* [ ] Initial API connector for Homebox.
### Phase 3: Semantic Search & Expansion
* [ ] Integration of Spring AI vector embeddings.
* [ ] Implementation of additional API targets (WikiJS).
* [ ] Launch of a public-facing demo.
---
## Setup & Deployment
The project is orchestrated via Docker Compose for high portability.
1. Clone the Repository.
2. Environment Setup: Configure `.env.local` with your database credentials and AI API keys.
```
DB_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl
DB_TEST_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl_test
DB_USERNAME={username}
DB_PASSWORD={password}
OPENAI_KEY={api-key} # or LLM provider of choice
OPENAI_BASE_URL=https://api.openai.com/v1
PG_DRIVER_CLASS_NAME=org.postgresql.Driver
```
3. Setup both production and test pgvector databases using the official pgvector docker image or installing the pgvector extension to you existing PostGreSQL database.
4. Execute "npm i" and build gradle.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+8 -3
View File
@@ -1,6 +1,6 @@
plugins { plugins {
java java
id("org.springframework.boot") version "4.0.4" 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,8 +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.springframework.boot:spring-boot-starter-test") testImplementation("org.wiremock:wiremock-standalone:3.12.0")
} testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")}
dependencyManagement { dependencyManagement {
imports { imports {
@@ -49,6 +50,10 @@ dependencyManagement {
} }
} }
tasks.withType<JavaCompile> {
options.compilerArgs.add("-parameters")
}
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
} }
@@ -9,5 +9,4 @@ public class Application {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(Application.class, args); SpringApplication.run(Application.class, args);
} }
} }
@@ -0,0 +1,20 @@
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.allowed-origins}")
private String[] allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins(allowedOrigins)
.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;
}
}
@@ -0,0 +1,6 @@
package com.vaessl.app.connection;
import java.time.Instant;
public record AuthResponse(String serviceType, Instant expiresAt) {
}
@@ -0,0 +1,84 @@
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 jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
public class ConnectionController {
private static final String SUFFIX = "_CONNECTION_ID";
private final ConnectionService connectionService;
@PostMapping("/login")
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();
}
}
@@ -0,0 +1,31 @@
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 jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "connections",
uniqueConstraints = {@UniqueConstraint(columnNames = {"appUrl", "username"})})
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type")
@Getter
@Setter
@NoArgsConstructor
public abstract class ConnectionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String appUrl;
private String username;
}
@@ -0,0 +1,20 @@
package com.vaessl.app.connection;
import java.time.Instant;
public interface ConnectionProvider extends ServiceProvider {
void checkCredentials(ConnectionRequest request);
ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
default Instant getTokenExpiry(ConnectionEntity entity) {
return null;
}
}
@@ -0,0 +1,10 @@
package com.vaessl.app.connection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ConnectionRepository extends JpaRepository<ConnectionEntity, Long> {
ConnectionEntity findByAppUrlAndUsername(String appUrl, String username);
}
@@ -0,0 +1,22 @@
package com.vaessl.app.connection;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
public record ConnectionRequest(@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType,
String username, String password, String apiKey,
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest {
if (stayLoggedIn == null) {
stayLoggedIn = false;
}
}
public ConnectionRequest(String appUrl, String serviceType, String username,
String password, Boolean stayLoggedIn) {
this(appUrl, serviceType, username, password, null, stayLoggedIn);
}
}
@@ -0,0 +1,18 @@
package com.vaessl.app.connection;
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;
}
}
}
@@ -0,0 +1,63 @@
package com.vaessl.app.connection;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.vaessl.app.exception.WrongServiceTypeException;
@Service
public class ConnectionService {
private final Map<String, ConnectionProvider> providerRegistry;
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 LoginResult login(ConnectionRequest request) {
ConnectionProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new WrongServiceTypeException();
}
provider.checkCredentials(request);
ConnectionResponse response = provider.authenticate(request);
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
ConnectionEntity saved;
if (existing != null) {
provider.updateToRepository(existing, response);
saved = existing;
} else {
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
saved = cRepository.save(newEntity);
}
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);
}
}
@@ -0,0 +1,7 @@
package com.vaessl.app.connection;
import java.time.Instant;
public record ConnectionStatusResponse(String serviceType, String appUrl, String username,
Instant expiresAt, boolean connected) {
}
@@ -0,0 +1,16 @@
package com.vaessl.app.connection;
public enum Endpoint {
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS(
"/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/items"), SEARCH("/search");
private final String value;
private Endpoint(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
@@ -0,0 +1,109 @@
package com.vaessl.app.connection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.vaessl.app.exception.EmptyCredentialsException;
import com.vaessl.app.exception.RemoteApiException;
import static com.vaessl.app.connection.Endpoint.*;
@Component
public class HomeboxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeboxConnectionProvider(RestClient.Builder restClientBuilder,
ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override
public void checkCredentials(ConnectionRequest request) {
if (request.username() == null || request.password() == null) {
List<String> missingFields = new ArrayList<>();
if (request.username() == null) {
missingFields.add("username");
}
if (request.password() == null) {
missingFields.add("password");
}
throw new EmptyCredentialsException(List.copyOf(missingFields));
}
}
@Override
public String getServiceType() {
return "HOMEBOX";
}
@Override
public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", request.username(), "password",
request.password(), "stayLoggedIn", request.stayLoggedIn());
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().post()
.uri(HOMEBOX_LOGIN.getValue()).body(homeboxPayload).retrieve()
.body(HomeboxLoginResponse.class);
if (hbResponse == null) {
throw new RemoteApiException(request.appUrl());
}
Map<String, Object> attachmentToken = new HashMap<>();
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
String hbRawToken = hbResponse.token();
String token = hbRawToken.startsWith("Bearer ") ? hbRawToken.substring(7) : hbRawToken;
return new ConnectionResponse(token, hbResponse.expiresAt(), attachmentToken);
}
@Override
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
}
@Override
public ConnectionEntity connectionToEntity(ConnectionRequest request,
ConnectionResponse response) {
return HomeboxEntity.from(request, response);
}
@Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
if (existing instanceof HomeboxEntity hbE) {
hbE.setToken(response.token());
hbE.setExpiresAt(response.expiresAt());
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
cRepository.save(hbE);
}
}
@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,32 @@
package com.vaessl.app.connection;
import java.time.Instant;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Entity
@DiscriminatorValue("HOMEBOX")
@Getter
@Setter
public class HomeboxEntity extends ConnectionEntity {
private String token;
private String attachmentToken;
private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity();
he.setAppUrl(request.appUrl());
he.setUsername(request.username());
he.setToken(response.token());
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
he.setExpiresAt(response.expiresAt());
return he;
}
}
@@ -0,0 +1,6 @@
package com.vaessl.app.connection;
import java.time.Instant;
public record LoginResult(Long connectionId, Instant expiresAt) {
}
@@ -0,0 +1,10 @@
package com.vaessl.app.connection;
public interface ServiceProvider {
/**
* Returns the service type key used to look up this provider in a registry, e.g.
* {@code "HOMEBOX"}.
*/
String getServiceType();
}
@@ -0,0 +1,14 @@
package com.vaessl.app.connection;
import lombok.Getter;
@Getter
public enum ServiceType {
HOMEBOX("HOMEBOX");
private final String value;
private ServiceType(String value) {
this.value = value;
}
}
@@ -0,0 +1,4 @@
package com.vaessl.app.exception;
public class ConnectionNotFoundException extends RuntimeException {
}
@@ -0,0 +1,13 @@
package com.vaessl.app.exception;
import java.util.List;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class EmptyCredentialsException extends RuntimeException {
private final List<String> missingFields;
}
@@ -0,0 +1,42 @@
package com.vaessl.app.exception;
import org.springframework.http.HttpStatus;
import com.fasterxml.jackson.annotation.JsonValue;
public enum ErrorMessage {
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST,
"Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(HttpStatus.UNAUTHORIZED,
"Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
HttpStatus.SERVICE_UNAVAILABLE,
"The target URL is unreachable."), SERVER_ERROR_GENERAL(
"The external app returned a server error: "), WRONG_SERVICE_TYPE(
HttpStatus.NOT_FOUND,
"No such service type."), CONNECTION_NOT_FOUND(
HttpStatus.NOT_FOUND,
"No active connection found for this service."), REMOTE_API_EMPTY_RESPONSE(
HttpStatus.BAD_GATEWAY,
"Remote API returned empty response for ");
private final HttpStatus status;
private final String message;
private ErrorMessage(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
private ErrorMessage(String message) {
this.status = null;
this.message = message;
}
public HttpStatus getStatus() {
return status;
}
@JsonValue
public String getMessage() {
return message;
}
}
@@ -0,0 +1,72 @@
package com.vaessl.app.exception;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import static com.vaessl.app.exception.ErrorMessage.*;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
String defaultMessages = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage).collect(Collectors.joining(", "));
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
}
@ExceptionHandler(HttpClientErrorException.Unauthorized.class)
public ProblemDetail handleUnauthorizedAccess(HttpClientErrorException e) {
return ProblemDetail.forStatusAndDetail(UNAUTHORIZED_WRONG_LOGIN.getStatus(),
UNAUTHORIZED_WRONG_LOGIN.getMessage());
}
@ExceptionHandler(ResourceAccessException.class)
public ProblemDetail handleNoConnection(ResourceAccessException e) {
return ProblemDetail.forStatusAndDetail(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getStatus(),
SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
}
@ExceptionHandler(HttpServerErrorException.class)
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
return ProblemDetail.forStatusAndDetail(e.getStatusCode(),
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
}
@ExceptionHandler(WrongServiceTypeException.class)
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(),
WRONG_SERVICE_TYPE.getMessage());
}
@ExceptionHandler(EmptyCredentialsException.class)
public ProblemDetail handleEmptyCredentials(EmptyCredentialsException e) {
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
}
@ExceptionHandler(ConnectionNotFoundException.class)
public ProblemDetail handleConnectionNotFound(ConnectionNotFoundException e) {
return ProblemDetail.forStatusAndDetail(CONNECTION_NOT_FOUND.getStatus(), CONNECTION_NOT_FOUND.getMessage());
}
@ExceptionHandler(RemoteApiException.class)
public ProblemDetail handleRemoteApiException(RemoteApiException e) {
return ProblemDetail.forStatusAndDetail(REMOTE_API_EMPTY_RESPONSE.getStatus(),
REMOTE_API_EMPTY_RESPONSE.getMessage() + e.getAppUrl());
}
}
@@ -0,0 +1,11 @@
package com.vaessl.app.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class RemoteApiException extends RuntimeException {
private final String appUrl;
}
@@ -0,0 +1,4 @@
package com.vaessl.app.exception;
public class WrongServiceTypeException extends RuntimeException {
}
@@ -0,0 +1,79 @@
package com.vaessl.app.search;
import java.util.List;
import java.util.Map;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.vaessl.app.connection.ConnectionEntity;
import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.connection.HomeboxEntity;
import com.vaessl.app.exception.ConnectionNotFoundException;
import com.vaessl.app.exception.RemoteApiException;
import static com.vaessl.app.connection.Endpoint.*;
@Component
public class HomeboxSearchProvider implements SearchProvider {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeboxSearchProvider(RestClient.Builder restClientBuilder,
ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override
public String getServiceType() {
return "HOMEBOX";
}
@Override
public Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable) {
ConnectionEntity entity =
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
if (!(entity instanceof HomeboxEntity hbEntity)) {
throw new ConnectionNotFoundException();
}
HomeboxSearchResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().get()
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue())
.queryParam("q", request.query())
.queryParam("page", pageable.getPageNumber() + 1)
.queryParam("pageSize", pageable.getPageSize()).build())
.headers(h -> h.setBearerAuth(
hbEntity.getToken())).retrieve().body(HomeboxSearchResponse.class);
if (hbResponse == null) {
throw new RemoteApiException(request.appUrl());
}
List<SearchResponse> items = hbResponse.items().stream().map(i -> {
String title = i.name();
String description = i.description();
Map<String, Object> extraSearchResponseData = Map.of("location", i.location());
return new SearchResponse(title, description, extraSearchResponseData);
}).toList();
return new PageImpl<>(items, pageable, hbResponse.total());
}
private record HomeboxSearchResponse(int page, int pageSize, int total,
List<HomeboxItem> items) {
}
private record HomeboxItem(String name, String description, HomeboxLocation location) {
}
private record HomeboxLocation(String name, String description) {
}
}
@@ -0,0 +1,14 @@
package com.vaessl.app.search;
import java.util.List;
import org.springframework.data.domain.Page;
public record PagedSearchResponse<T>(List<T> content, int page, int pageSize, long totalElements,
boolean first, boolean last, String sort) {
public static <T> PagedSearchResponse<T> from(Page<T> pageResult) {
return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(),
pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(),
pageResult.isLast(), pageResult.getSort().toString());
}
}
@@ -0,0 +1,41 @@
package com.vaessl.app.search;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
public class SearchController {
private final SearchService searchService;
/**
* Executes a paged search against the requested service. Returns {@code 401 Unauthorized} if
* there is no active session.
*/
@PostMapping("/search")
public ResponseEntity<PagedSearchResponse<SearchResponse>> search(
@Valid @RequestBody SearchRequest request,
@PageableDefault(size = 20) Pageable pageable, HttpServletRequest httpReq) {
HttpSession session = httpReq.getSession(false);
if (session == null
|| session.getAttribute(request.serviceType() + "_CONNECTION_ID") == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Page<SearchResponse> result = searchService.search(request, pageable);
return ResponseEntity.ok(PagedSearchResponse.from(result));
}
}
@@ -0,0 +1,20 @@
package com.vaessl.app.search;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import com.vaessl.app.connection.ServiceProvider;
/**
* Implemented by any service that supports querying its remote API for items.
*/
public interface SearchProvider extends ServiceProvider {
/**
* Executes a search query against the remote service and returns matching results.
*
* @param request the search request containing the query string, app URL, and user credentials
* @return a list of Page<SearchResponse> items matching the query
*/
Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable);
}
@@ -0,0 +1,7 @@
package com.vaessl.app.search;
import jakarta.validation.constraints.NotBlank;
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
@NotBlank String serviceType) {
}
@@ -0,0 +1,16 @@
package com.vaessl.app.search;
import java.util.Map;
public record SearchResponse(String title, String description, Map<String, Object> extraData) {
public String getExtra(String key) {
if (extraData == null) {
return null;
} else {
Object value = extraData.get(key);
return value != null ? String.valueOf(value) : null;
}
}
}
@@ -0,0 +1,42 @@
package com.vaessl.app.search;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import com.vaessl.app.exception.WrongServiceTypeException;
@Service
public class SearchService {
private final Map<String, SearchProvider> providerRegistry;
public SearchService(List<SearchProvider> providers) {
this.providerRegistry = Map.copyOf(providers.stream()
.collect(Collectors.toMap(SearchProvider::getServiceType, p -> p)));
}
/**
* Dispatches the paged search request to the provider registered for
* {@link SearchRequest#serviceType()}.
*
* @param request the search request
* @param pageable the Pageable interface
* @return results returned by the matching provider
* @throws WrongServiceTypeException if no provider is registered for the given service type
*/
public Page<SearchResponse> search(SearchRequest request, Pageable pageable) {
SearchProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new WrongServiceTypeException();
}
return provider.getSearchResults(request, pageable);
}
}
@@ -0,0 +1,12 @@
{"properties": [
{
"name": "spring.session.store-type",
"type": "java.lang.String",
"description": "A description for 'spring.session.store-type'"
},
{
"name": "vaessl.allowed-origins",
"type": "java.lang.String",
"description": "Comma-separated list of allowed CORS origins"
}
]}
+15 -9
View File
@@ -2,18 +2,19 @@ spring:
application: application:
name: vaessl name: vaessl
config: config:
import: "optional:file:.env.local[.properties]" import:
- "optional:file:.env.local[.properties]"
- "optional:file: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}
jpa: jpa:
hibernate: hibernate:
ddl-auto: create-drop ddl-auto: update
show-sql: true show-sql: true
ai: ai:
openai: openai:
base-url: ${OPENAI_BASE_URL} base-url: ${OPENAI_BASE_URL}
@@ -21,7 +22,12 @@ spring:
chat: chat:
options: options:
model: gpt-4o-mini model: gpt-4o-mini
session:
logging: store-type: jdbc
level: jdbc:
org.springframework.boot.context.config: TRACE initialize-schema: always
server:
servlet:
context-path: /api
vaessl:
allowed-origins: ${ALLOWED_ORIGINS}
@@ -16,14 +16,16 @@ import static org.assertj.core.api.Assertions.assertThat;
@ActiveProfiles("test") @ActiveProfiles("test")
class ApplicationTests { class ApplicationTests {
@Autowired @Autowired
private DataSource dataSource; private DataSource dataSource;
@Test
void contextLoads() {}
@Test @Test
void connectionToTestDbWorks() throws SQLException { void connectionToTestDbWorks() throws SQLException {
try (Connection connection = dataSource.getConnection()) { try (Connection connection = dataSource.getConnection()) {
assertThat(connection.getMetaData().getURL()).contains("vaessl_test"); assertThat(connection.getMetaData().getURL()).contains("vaessl_test");
} }
} }
} }
@@ -0,0 +1,13 @@
package com.vaessl.app;
public final class Mockdata {
private Mockdata() {}
public static final String MOCK_URL = "http://localhost:1234";
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
public static final String MOCK_USER = "user";
public static final String MOCK_PASS = "pw";
public static final String MOCK_TITLE = "title";
public static final String MOCK_DESCRIPTION = "desc";
}
@@ -0,0 +1,154 @@
package com.vaessl.app.connection;
import static com.vaessl.app.Mockdata.*;
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 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(MOCK_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(), MOCK_USER, MOCK_PASS);
}
}
@@ -0,0 +1,49 @@
package com.vaessl.app.connection;
import static com.vaessl.app.Mockdata.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import com.vaessl.app.exception.EmptyCredentialsException;
@ExtendWith(MockitoExtension.class)
class ConnectionServiceTest {
@Mock
private ConnectionProvider mockProvider;
@Mock
private ConnectionRepository mockRepo;
private ConnectionService connectionService;
@BeforeEach
void setup() {
when(mockProvider.getServiceType()).thenReturn(MOCK_SERVICE_TYPE);
connectionService = new ConnectionService(List.of(mockProvider), mockRepo);
}
@Test
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
ConnectionRequest request =
new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
doThrow(new EmptyCredentialsException(List.of("username"))).when(mockProvider)
.checkCredentials(request);
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
verify(mockProvider, never()).authenticate(any());
}
}
@@ -0,0 +1,25 @@
package com.vaessl.app.connection;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import com.vaessl.app.exception.EmptyCredentialsException;
import static com.vaessl.app.Mockdata.*;
import static org.assertj.core.api.Assertions.assertThat;
class HomeboxConnectionProviderTest {
private final HomeboxConnectionProvider provider = new HomeboxConnectionProvider(null, null);
@Test
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false);
EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> {
provider.checkCredentials(request);
});
assertThat(exception.getMissingFields()).containsExactly("username", "password");
}
}
@@ -0,0 +1,202 @@
package com.vaessl.app.connection;
import static com.vaessl.app.Mockdata.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import com.github.tomakehurst.wiremock.http.Fault;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import static org.assertj.core.api.Assertions.assertThat;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.vaessl.app.connection.Endpoint.*;
import static com.vaessl.app.exception.ErrorMessage.*;
import static com.vaessl.app.connection.ServiceType.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
@WireMockTest
class HomeboxIntegrationTest {
@Autowired
TestRestTemplate restTemplate;
@Autowired
ConnectionRepository cRepository;
String okJsonHomeboxResponse = """
{
"token": "fake-jwt-token",
"attachmentToken": "fake-attach",
"expiresAt": "2026-04-26T02:23:13Z"
}
""";
/**
* Returns Token and status code OK when login is successful.
*
* @param wm the WiremockRuntimeInfo object
*/
@Test
void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse)));
ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
DocumentContext documentContext = JsonPath.parse(response.getBody());
String serviceType = documentContext.read("$.serviceType");
assertThat(serviceType).isEqualTo("HOMEBOX");
String expiresAt = documentContext.read("$.expiresAt", String.class);
assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
/**
* Tests the Unauthorized custom exception.
*
* @param wm the WiremockRuntimeInfo object
*/
@Test
void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
}
/**
* Tests a server error from the external api.
*
* @param wm the WiremockRuntimeInfo object
*/
@Test
void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
}
/**
* Checks when the service is unavailable or the app URL is wrong.
*/
@Test
void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getValue())
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
ConnectionRequest badRequest = connectionRequest(wm);
ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
}
/**
* Checks if any login fields are empty since all of them are mandatory.
*/
@Test
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
}
/**
* Test the exception when there is an input for serviceType but it's unsupported.
*/
@Test
void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) {
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(wm.getHttpBaseUrl(),
"wrong-service-type", MOCK_USER, MOCK_PASS, false);
ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage());
}
/**
* Tests the succesfull persistance of Homebox credential response to the database.
*
* @param wm the WiremockRuntimeInfo object
*/
@Test
void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) {
stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue()))
.willReturn(okJson(okJsonHomeboxResponse)));
ConnectionRequest request = connectionRequest(wm);
ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ConnectionEntity dbEntry =
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
assertThat(dbEntry).isNotNull();
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
assertThat(dbEntry.getUsername()).isEqualTo(request.username());
if (dbEntry instanceof HomeboxEntity hbE) {
assertThat(hbE.getToken()).isEqualTo("fake-jwt-token");
assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach");
assertThat(hbE.getExpiresAt().toString()).hasToString("2026-04-26T02:23:13Z");
}
}
@Test
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(),
HOMEBOX.getValue(), MOCK_USER, null, false);
ResponseEntity<String> response =
restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
}
/**
* Creates a valid connection request with a mock Api through WireMockRuntimeInfo.
*
* @param wm the WiremockRuntimeInfo object
* @return a mock api connection request.
*/
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), MOCK_USER, MOCK_PASS,
null);
}
}
@@ -0,0 +1,35 @@
package com.vaessl.app.search;
import static com.vaessl.app.Mockdata.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.exception.ConnectionNotFoundException;
@ExtendWith(MockitoExtension.class)
class HomeboxSearchProviderTest {
@Mock
private ConnectionRepository mockRepo;
@InjectMocks
private HomeboxSearchProvider provider;
@Test
void shouldReturnConnectionNotFoundException() {
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "test query", "HOMEBOX");
Pageable pageable = PageRequest.of(0, 10);
assertThrows(ConnectionNotFoundException.class,
() -> provider.getSearchResults(request, pageable));
}
}
@@ -0,0 +1,152 @@
package com.vaessl.app.search;
import static com.vaessl.app.Mockdata.MOCK_PASS;
import static com.vaessl.app.Mockdata.MOCK_USER;
import static com.vaessl.app.connection.Endpoint.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
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;
@SpringBootTest
@AutoConfigureMockMvc
@WireMockTest
class SearchControllerTest {
@Autowired
MockMvc mockMvc;
private static final String QUERY_ALL_ITEMS = HOMEBOX_QUERY_ALL_ITEMS.getValue();
private static final String LOGIN_PATH = LOGIN.getValue();
private static final String SEARCH_REQUEST = SEARCH.getValue();
private static final String VALID_HOMEBOX_LOGIN_RESPONSE = """
{
"token": "fake-bearer-token",
"attachmentToken": "fake-attach-token",
"expiresAt": "2099-01-01T00:00:00Z"
}
""";
private static final String VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE = """
{
"page": -1,
"pageSize": -1,
"total": 1,
"items": [
{
"id": "c643e7f9-93d0-4b5f-ae4d-e1c2d90389e0",
"assetId": "000-001",
"name": "MacBook Pro A1398",
"description": "Running Linux (Fedora)",
"quantity": 1,
"insured": false,
"archived": false,
"createdAt": "2026-05-13T19:52:20.016176Z",
"updatedAt": "2026-05-14T12:39:11.836403Z",
"purchasePrice": 0,
"location": {
"id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636",
"name": "Server Schrank Ikea weiß",
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
"createdAt": "2026-05-13T19:55:55.817576Z",
"updatedAt": "2026-05-14T12:37:24.396651Z"
},
"tags": [],
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
"soldTime": "0001-01-01T00:00:00Z"
}
]
}
""";
@Test
void shouldReturnListOfQueriedHomeboxItems(WireMockRuntimeInfo wm) throws Exception {
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
WireMock.stubFor(WireMock.get(WireMock.urlPathEqualTo(QUERY_ALL_ITEMS))
.willReturn(WireMock.okJson(VALID_HOMEBOX_ALL_ITEMS_QUERY_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(post(SEARCH_REQUEST).cookie(sessionCookie)
.contentType(MediaType.APPLICATION_JSON).content(searchRequestBody(wm, "HOMEBOX")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398"))
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].extraData.location.name")
.value("Server Schrank Ikea weiß"));
}
@Test
void shouldReturnUnauthorizedWhenNoSession() throws Exception {
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON).content("""
{
"appUrl": "http://irrelevant",
"query": "Item",
"serviceType": "HOMEBOX",
"username": "irrelevant"
}
""")).andExpect(status().isUnauthorized());
}
@Test
void shouldReturnUnauthorizedWhenSessionHasNoConnectionForRequestedServiceType(
WireMockRuntimeInfo wm) throws Exception {
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_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(
post(SEARCH_REQUEST).cookie(sessionCookie).contentType(MediaType.APPLICATION_JSON)
.content(searchRequestBody(wm, "OTHER_SERVICETYPE")))
.andExpect(status().isUnauthorized());
}
private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) {
return """
{
"appUrl": "%s",
"query": "Item",
"serviceType": "%s",
"username": "%s"
}
""".formatted(wm.getHttpBaseUrl(), serviceType, MOCK_USER);
}
private String connectionRequestBody(WireMockRuntimeInfo wm) {
return """
{
"appUrl": "%s",
"serviceType": "HOMEBOX",
"username": "%s",
"password": "%s"
}
""".formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
}
}
@@ -0,0 +1,33 @@
package com.vaessl.app.search;
import static com.vaessl.app.Mockdata.*;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import org.junit.jupiter.api.Test;
class SearchResponseTest {
@Test
void shouldReturnNullWhenExtraDataIsNull() {
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, null);
assertThat(response.getExtra(null)).isNull();
}
@Test
void shouldReturnNullWhenExtraDataKeyIsMissing() {
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
assertThat(response.getExtra("missing")).isNull();
}
@Test
void shouldReturnExtraDataValue() {
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
assertThat(response.getExtra("key")).contains("value");
}
}
@@ -2,7 +2,7 @@
Before developing on code-server I configure a Dockerfile to install all packages needed for Spring Boot, Java and Vite. Before developing on code-server I configure a Dockerfile to install all packages needed for Spring Boot, Java and Vite.
I install openjdk 25, nodejs 24.x and yarn and set the environment variables for Java. I install openjdk 25, nodejs 24.x and yarn and set the environment variables for Java (and the /config/.local/bin folder that gets used for tools like Claude Code).
Since the linuxserver code-server image doesn't come with root access for its default user abc out of the box every privileged action will be baked in here: Since the linuxserver code-server image doesn't come with root access for its default user abc out of the box every privileged action will be baked in here:
@@ -20,7 +20,7 @@ RUN apt update && apt install -y \
# Set Java Environment # Set Java Environment
ENV JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ENV JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
ENV PATH="$JAVA_HOME/bin:${PATH}" ENV PATH="/config/.local/bin:$JAVA_HOME/bin:${PATH}"
``` ```
@@ -0,0 +1,57 @@
**vaessl: Setup API client**
# Locally hosted API client
I chose [Hoppscotch ](https://docs.hoppscotch.io/) as API client since it is free, open-source, has a Docker support and can be locally hosted. Together with its browser extension it can be run in the browser supporting my goal of a flexible, browser-based development environment where I can securely access and interact with my entire stack remotely.
```
services:
hoppscotch:
image: hoppscotch/hoppscotch
container_name: hoppscotch
restart: unless-stopped
# ATTENTION: Run command and entrypoint for the first time and then comment it out
#command: '-c "pnpx prisma migrate deploy"'
#entrypoint: sh
environment:
PUID: 1000
PGID: 1000
# Prisma Config
DATABASE_URL: postgresql://user:pw@192.168.1.208:5432/hoppscotch
# (Optional) By default, the AIO container (when in subpath access mode) exposes the endpoint on port 80. Use this setting to specify a different port if needed.
HOPP_AIO_ALTERNATE_PORT: 80
# Sensitive Data Encryption Key while storing in Database (32 character)
DATA_ENCRYPTION_KEY: 'key'
# Whitelisted origins for the Hoppscotch App.
# This list controls which origins can interact with the app through cross-origin comms.
# - localhost ports (3170, 3000, 3100): app, backend, development servers and services
# - app://localhost_3200: Bundle server origin identifier
# NOTE: `3200` here refers to the bundle server (port 3200) that provides the bundles,
# NOT where the app runs. The app itself uses the `app://` protocol with dynamic
# bundle names like `app://{bundle-name}/`
WHITELISTED_ORIGINS: http://localhost:3170,http://localhost:3009,http://localhost:3101,app://localhost_3200,app://hoppscotch
# Base URLs
VITE_BASE_URL: http://localhost:3009
VITE_SHORTCODE_BASE_URL: http://localhost:3009
VITE_ADMIN_URL: http://localhost:3101
# Backend URLs
VITE_BACKEND_GQL_URL: http://localhost:3170/graphql
VITE_BACKEND_WS_URL: wss://localhost:3170/graphql
VITE_BACKEND_API_URL: http://localhost:3170/v1
# Terms Of Service And Privacy Policy Links (Optional)
VITE_APP_TOS_LINK: https://docs.hoppscotch.io/support/terms
VITE_APP_PRIVACY_POLICY_LINK: https://docs.hoppscotch.io/support/privacy
# Set to `true` for subpath based access
ENABLE_SUBPATH_BASED_ACCESS: false
ports:
- '3170:3170'
- '3101:3100'
- '3009:3000'
networks:
- postgres_pg_network
networks:
postgres_pg_network:
external: true
```
@@ -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
@@ -0,0 +1,177 @@
**vaessl: Login Architecture**
# Backend
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
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.
***ConnectionEntity.java***
```java
@Entity
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type")
public abstract class ConnectionEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String appUrl;
private String username;
}
```
***HomeboxEntity.java***
```java
@Entity
@DiscriminatorValue("HOMEBOX")
public class HomeboxEntity extends ConnectionEntity {
private String token;
private String attachmentToken;
private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity();
he.setAppUrl(request.appUrl());
he.setUsername(request.username());
he.setToken(response.token());
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
he.setExpiresAt(response.expiresAt());
return he;
}
}
```
## The Provider Pattern (Logic Layer)
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***
```java
public interface ConnectionProvider {
String getServiceType();
void checkCredentials(ConnectionRequest request);
ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
default Instant getTokenExpiry(ConnectionEntity entity) {
return null;
}
}
```
- `checkCredentials` — validates that required fields are present before attempting the remote call (throws `EmptyCredentialsException` with a list of missing fields)
- `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update
- `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag
***HomeboxConnectionProvider.java***
```java
@Component
public class HomeboxConnectionProvider implements ConnectionProvider {
@Override
public String getServiceType() { return "HOMEBOX"; }
@Override
public void checkCredentials(ConnectionRequest request) {
// throws EmptyCredentialsException if username or password is null
}
@Override
public ConnectionResponse authenticate(ConnectionRequest request) {
// POST to Homebox /api/v1/users/login, returns token + attachmentToken + expiresAt
}
@Override
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
}
@Override
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
return HomeboxEntity.from(request, response);
}
@Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
// casts to HomeboxEntity and updates token, attachmentToken, expiresAt
}
@Override
public Instant getTokenExpiry(ConnectionEntity entity) {
return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null;
}
}
```
***ConnectionService.java***
```java
@Service
public class ConnectionService {
// auto-discovers all ConnectionProvider beans into a serviceType → provider map
public LoginResult login(ConnectionRequest request) {
provider.checkCredentials(request);
ConnectionResponse response = provider.authenticate(request);
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
// insert or update, then return LoginResult(connectionId, expiresAt)
}
public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
// looks up entity by id, calls provider.getTokenExpiry() to compute connected flag
}
}
```
## DTOs
### Request / Internal
- **`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`).
### Response (returned to frontend)
- **`LoginResult`** — `connectionId`, `expiresAt`. Returned by `ConnectionService.login()` and used by the controller to populate the session.
- **`AuthResponse`** — `serviceType`, `expiresAt`. Returned by `POST /api/login` to the frontend.
- **`ConnectionStatusResponse`** — `serviceType`, `appUrl`, `username`, `expiresAt`, `connected`. Returned by `GET /api/connections/status`.
## Supported Service Types
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.
+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,
},
},
},
}
}) })