feat: zero-config first-run experience with Logto bootstrap
- logto-bootstrap.sh: API-driven init script that creates SPA app, M2M app, and default user (camel/camel) via Logto Management API. Reads m-default secret from DB, then removes seeded apps with known secrets (security hardening). Idempotent. - PublicConfigController: /api/config public endpoint serves Logto client ID from bootstrap output file (runtime, not build-time) - Frontend: LoginPage + CallbackPage fetch config from /api/config instead of import.meta.env (fixes Vite build-time baking issue) - Docker Compose: logto-bootstrap init service with health-gated dependency chain, shared volume for bootstrap config - SecurityConfig: permit /api/config without auth Flow: docker compose up → bootstrap creates apps/user → SPA fetches config → login page shows → sign in with Logto → camel/camel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package net.siegeln.cameleer.saas.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class PublicConfigController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PublicConfigController.class);
|
||||
private static final String BOOTSTRAP_FILE = "/data/bootstrap/logto-bootstrap.json";
|
||||
|
||||
@Value("${cameleer.identity.logto-endpoint:}")
|
||||
private String logtoEndpoint;
|
||||
|
||||
@Value("${cameleer.identity.spa-client-id:}")
|
||||
private String spaClientId;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@GetMapping("/api/config")
|
||||
public Map<String, String> config() {
|
||||
String clientId = spaClientId;
|
||||
|
||||
// Fall back to bootstrap file if env var not set
|
||||
if (clientId == null || clientId.isEmpty()) {
|
||||
clientId = readBootstrapClientId();
|
||||
}
|
||||
|
||||
// Use external Logto endpoint for browser redirects
|
||||
String endpoint = logtoEndpoint;
|
||||
if (endpoint == null || endpoint.isEmpty()) {
|
||||
endpoint = "http://localhost:3001";
|
||||
}
|
||||
|
||||
return Map.of(
|
||||
"logtoEndpoint", endpoint,
|
||||
"logtoClientId", clientId != null ? clientId : ""
|
||||
);
|
||||
}
|
||||
|
||||
private String readBootstrapClientId() {
|
||||
try {
|
||||
File file = new File(BOOTSTRAP_FILE);
|
||||
if (file.exists()) {
|
||||
JsonNode node = objectMapper.readTree(file);
|
||||
return node.has("spaClientId") ? node.get("spaClientId").asText() : "";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read bootstrap config: {}", e.getMessage());
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
.requestMatchers("/auth/verify").permitAll()
|
||||
.requestMatchers("/api/config").permitAll()
|
||||
.requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license").permitAll()
|
||||
.requestMatchers("/assets/**", "/favicon.ico").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
|
||||
@@ -37,6 +37,7 @@ cameleer:
|
||||
logto-endpoint: ${LOGTO_ENDPOINT:}
|
||||
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
|
||||
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
|
||||
spa-client-id: ${LOGTO_SPA_CLIENT_ID:}
|
||||
runtime:
|
||||
max-jar-size: 209715200
|
||||
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
|
||||
|
||||
Reference in New Issue
Block a user