Add React UI with Execution Explorer, auth, and standalone deployment
- Scaffold Vite + React + TypeScript frontend in ui/ with full design system (dark/light themes) matching the HTML mockups - Implement Execution Explorer page: search filters, results table with expandable processor tree and exchange detail sidebar, pagination - Add UI authentication: UiAuthController (login/refresh endpoints), JWT filter handles ui: subject prefix, CORS configuration - Shared components: StatusPill, DurationBar, StatCard, AppBadge, FilterChip, Pagination — all using CSS Modules with design tokens - API client layer: openapi-fetch with auth middleware, TanStack Query hooks for search/detail/snapshot queries, Zustand for state - Standalone deployment: Nginx Dockerfile, K8s Deployment + ConfigMap + NodePort (30080), runtime config.js for API base URL - Embedded mode: maven-resources-plugin copies ui/dist into JAR static resources, SPA forward controller for client-side routing - CI/CD: UI build step, Docker build/push for server-ui image, K8s deploy step for UI, UI credential secrets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,13 +43,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
if (token != null) {
|
||||
try {
|
||||
String agentId = jwtService.validateAndExtractAgentId(token);
|
||||
if (agentRegistryService.findById(agentId) != null) {
|
||||
String subject = jwtService.validateAndExtractAgentId(token);
|
||||
if (subject.startsWith("ui:")) {
|
||||
// UI user token — authenticate directly without agent registry lookup
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(agentId, null, List.of());
|
||||
new UsernamePasswordAuthenticationToken(subject, null, List.of());
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} else if (agentRegistryService.findById(subject) != null) {
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(subject, null, List.of());
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} else {
|
||||
log.debug("JWT valid but agent not found: {}", agentId);
|
||||
log.debug("JWT valid but agent not found: {}", subject);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("JWT validation failed: {}", e.getMessage());
|
||||
|
||||
@@ -10,11 +10,16 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Spring Security configuration for JWT-based stateless authentication.
|
||||
* <p>
|
||||
* Public endpoints: health, agent registration, refresh, API docs, Swagger UI.
|
||||
* Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources.
|
||||
* All other endpoints require a valid JWT access token.
|
||||
*/
|
||||
@Configuration
|
||||
@@ -24,8 +29,10 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http,
|
||||
JwtService jwtService,
|
||||
AgentRegistryService registryService) throws Exception {
|
||||
AgentRegistryService registryService,
|
||||
CorsConfigurationSource corsConfigurationSource) throws Exception {
|
||||
http
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource))
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
@@ -35,12 +42,18 @@ public class SecurityConfig {
|
||||
"/api/v1/health",
|
||||
"/api/v1/agents/register",
|
||||
"/api/v1/agents/*/refresh",
|
||||
"/api/v1/auth/**",
|
||||
"/api/v1/api-docs/**",
|
||||
"/api/v1/swagger-ui/**",
|
||||
"/swagger-ui/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui.html",
|
||||
"/error"
|
||||
"/error",
|
||||
"/",
|
||||
"/index.html",
|
||||
"/config.js",
|
||||
"/favicon.svg",
|
||||
"/assets/**"
|
||||
).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
@@ -51,4 +64,23 @@ public class SecurityConfig {
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource(SecurityProperties properties) {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
String origin = properties.getUiOrigin();
|
||||
if (origin != null && !origin.isBlank()) {
|
||||
config.setAllowedOrigins(List.of(origin));
|
||||
} else {
|
||||
config.setAllowedOrigins(List.of("http://localhost:5173"));
|
||||
}
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
config.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ public class SecurityProperties {
|
||||
private long refreshTokenExpiryMs = 604_800_000;
|
||||
private String bootstrapToken;
|
||||
private String bootstrapTokenPrevious;
|
||||
private String uiUser;
|
||||
private String uiPassword;
|
||||
private String uiOrigin;
|
||||
|
||||
public long getAccessTokenExpiryMs() {
|
||||
return accessTokenExpiryMs;
|
||||
@@ -45,4 +48,28 @@ public class SecurityProperties {
|
||||
public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) {
|
||||
this.bootstrapTokenPrevious = bootstrapTokenPrevious;
|
||||
}
|
||||
|
||||
public String getUiUser() {
|
||||
return uiUser;
|
||||
}
|
||||
|
||||
public void setUiUser(String uiUser) {
|
||||
this.uiUser = uiUser;
|
||||
}
|
||||
|
||||
public String getUiPassword() {
|
||||
return uiPassword;
|
||||
}
|
||||
|
||||
public void setUiPassword(String uiPassword) {
|
||||
this.uiPassword = uiPassword;
|
||||
}
|
||||
|
||||
public String getUiOrigin() {
|
||||
return uiOrigin;
|
||||
}
|
||||
|
||||
public void setUiOrigin(String uiOrigin) {
|
||||
this.uiOrigin = uiOrigin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.cameleer3.server.app.security;
|
||||
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Authentication endpoints for the UI.
|
||||
* <p>
|
||||
* Validates credentials against environment-configured username/password,
|
||||
* then issues JWTs with {@code ui:} prefixed subjects to distinguish
|
||||
* UI users from agent tokens in {@link JwtAuthenticationFilter}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
public class UiAuthController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UiAuthController.class);
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final SecurityProperties properties;
|
||||
|
||||
public UiAuthController(JwtService jwtService, SecurityProperties properties) {
|
||||
this.jwtService = jwtService;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||
String configuredUser = properties.getUiUser();
|
||||
String configuredPassword = properties.getUiPassword();
|
||||
|
||||
if (configuredUser == null || configuredUser.isBlank()
|
||||
|| configuredPassword == null || configuredPassword.isBlank()) {
|
||||
log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured");
|
||||
return ResponseEntity.status(401).body(Map.of("message", "UI authentication not configured"));
|
||||
}
|
||||
|
||||
if (!configuredUser.equals(request.username())
|
||||
|| !configuredPassword.equals(request.password())) {
|
||||
log.debug("UI login failed for user: {}", request.username());
|
||||
return ResponseEntity.status(401).body(Map.of("message", "Invalid credentials"));
|
||||
}
|
||||
|
||||
String subject = "ui:" + request.username();
|
||||
String accessToken = jwtService.createAccessToken(subject, "ui");
|
||||
String refreshToken = jwtService.createRefreshToken(subject, "ui");
|
||||
|
||||
log.info("UI user logged in: {}", request.username());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"accessToken", accessToken,
|
||||
"refreshToken", refreshToken
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) {
|
||||
try {
|
||||
String subject = jwtService.validateRefreshToken(request.refreshToken());
|
||||
if (!subject.startsWith("ui:")) {
|
||||
return ResponseEntity.status(401).body(Map.of("message", "Not a UI token"));
|
||||
}
|
||||
|
||||
String accessToken = jwtService.createAccessToken(subject, "ui");
|
||||
String refreshToken = jwtService.createRefreshToken(subject, "ui");
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"accessToken", accessToken,
|
||||
"refreshToken", refreshToken
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.debug("UI token refresh failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(401).body(Map.of("message", "Invalid refresh token"));
|
||||
}
|
||||
}
|
||||
|
||||
public record LoginRequest(String username, String password) {}
|
||||
public record RefreshRequest(String refreshToken) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cameleer3.server.app.web;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/**
|
||||
* SPA catch-all: forwards non-API, non-static requests to {@code index.html}
|
||||
* so that React Router can handle client-side routing.
|
||||
* <p>
|
||||
* Only active when the UI is embedded (i.e., {@code static/index.html} exists
|
||||
* in the classpath). When running standalone via Nginx, this is not needed.
|
||||
*/
|
||||
@Controller
|
||||
public class SpaForwardController {
|
||||
|
||||
@GetMapping(value = {
|
||||
"/login",
|
||||
"/executions",
|
||||
"/executions/{path:[^\\.]*}"
|
||||
})
|
||||
public String forward() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user