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:
@@ -16,6 +16,7 @@ LOGTO_JWK_SET_URI=http://logto:3001/oidc/jwks
|
|||||||
LOGTO_DB_PASSWORD=change_me_in_production
|
LOGTO_DB_PASSWORD=change_me_in_production
|
||||||
LOGTO_M2M_CLIENT_ID=
|
LOGTO_M2M_CLIENT_ID=
|
||||||
LOGTO_M2M_CLIENT_SECRET=
|
LOGTO_M2M_CLIENT_SECRET=
|
||||||
|
LOGTO_SPA_CLIENT_ID=
|
||||||
|
|
||||||
# Ed25519 Keys (mount PEM files)
|
# Ed25519 Keys (mount PEM files)
|
||||||
CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key
|
CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ services:
|
|||||||
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
|
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
|
||||||
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
|
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
|
||||||
TRUST_PROXY_HEADER: 1
|
TRUST_PROXY_HEADER: 1
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 15s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.logto.rule=PathPrefix(`/oidc`) || PathPrefix(`/interaction`)
|
- traefik.http.routers.logto.rule=PathPrefix(`/oidc`) || PathPrefix(`/interaction`)
|
||||||
@@ -49,16 +55,38 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- cameleer
|
- cameleer
|
||||||
|
|
||||||
|
logto-bootstrap:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
depends_on:
|
||||||
|
logto:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["sh", "/scripts/logto-bootstrap.sh"]
|
||||||
|
environment:
|
||||||
|
LOGTO_ENDPOINT: http://logto:3001
|
||||||
|
LOGTO_ADMIN_ENDPOINT: http://logto:3002
|
||||||
|
PG_HOST: postgres
|
||||||
|
PG_USER: ${POSTGRES_USER:-cameleer}
|
||||||
|
PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
||||||
|
volumes:
|
||||||
|
- ./docker/logto-bootstrap.sh:/scripts/logto-bootstrap.sh:ro
|
||||||
|
- bootstrapdata:/data
|
||||||
|
networks:
|
||||||
|
- cameleer
|
||||||
|
|
||||||
cameleer-saas:
|
cameleer-saas:
|
||||||
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
logto-bootstrap:
|
||||||
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./keys:/etc/cameleer/keys:ro
|
- ./keys:/etc/cameleer/keys:ro
|
||||||
- jardata:/data/jars
|
- jardata:/data/jars
|
||||||
|
- bootstrapdata:/data/bootstrap:ro
|
||||||
environment:
|
environment:
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
||||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||||||
@@ -139,3 +167,4 @@ volumes:
|
|||||||
chdata:
|
chdata:
|
||||||
acme:
|
acme:
|
||||||
jardata:
|
jardata:
|
||||||
|
bootstrapdata:
|
||||||
|
|||||||
198
docker/logto-bootstrap.sh
Normal file
198
docker/logto-bootstrap.sh
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Cameleer SaaS — Logto Bootstrap Script
|
||||||
|
# Creates SPA app, M2M app, default user via Logto Management API.
|
||||||
|
# Then removes seeded M2M apps with known secrets (security hardening).
|
||||||
|
# Idempotent: checks existence before creating.
|
||||||
|
|
||||||
|
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}"
|
||||||
|
LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://logto:3002}"
|
||||||
|
MGMT_API_RESOURCE="https://default.logto.app/api"
|
||||||
|
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
|
||||||
|
PG_HOST="${PG_HOST:-postgres}"
|
||||||
|
PG_USER="${PG_USER:-cameleer}"
|
||||||
|
PG_DB="logto"
|
||||||
|
|
||||||
|
SPA_APP_NAME="Cameleer SaaS"
|
||||||
|
M2M_APP_NAME="Cameleer SaaS Backend"
|
||||||
|
DEFAULT_USERNAME="camel"
|
||||||
|
DEFAULT_PASSWORD="camel"
|
||||||
|
|
||||||
|
REDIRECT_URIS='["http://localhost/callback","http://localhost:8080/callback","http://localhost:5173/callback"]'
|
||||||
|
POST_LOGOUT_URIS='["http://localhost","http://localhost:8080","http://localhost:5173"]'
|
||||||
|
|
||||||
|
log() { echo "[logto-bootstrap] $1"; }
|
||||||
|
|
||||||
|
# Install jq (not in postgres:16-alpine by default)
|
||||||
|
apk add --no-cache jq >/dev/null 2>&1
|
||||||
|
|
||||||
|
# --- Wait for Logto ---
|
||||||
|
log "Waiting for Logto to be ready..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if wget -qO /dev/null "${LOGTO_ENDPOINT}/oidc/.well-known/openid-configuration" 2>/dev/null; then
|
||||||
|
log "Logto is ready."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
[ "$i" -eq 60 ] && { log "ERROR: Logto not ready after 60s"; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Read m-default secret from Postgres ---
|
||||||
|
log "Reading m-default secret from database..."
|
||||||
|
M_DEFAULT_SECRET=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \
|
||||||
|
"SELECT secret FROM applications WHERE id = 'm-default' AND tenant_id = 'default';")
|
||||||
|
|
||||||
|
[ -z "$M_DEFAULT_SECRET" ] && { log "ERROR: m-default app not found in DB"; exit 1; }
|
||||||
|
log "Got m-default secret."
|
||||||
|
|
||||||
|
# --- Get Management API token ---
|
||||||
|
get_token() {
|
||||||
|
wget -qO- --post-data="grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all" \
|
||||||
|
--header="Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
"${LOGTO_ADMIN_ENDPOINT}/oidc/token" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
log "Getting Management API token..."
|
||||||
|
TOKEN=$(get_token "m-default" "$M_DEFAULT_SECRET" | jq -r '.access_token')
|
||||||
|
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; }
|
||||||
|
log "Got Management API token."
|
||||||
|
|
||||||
|
# --- Helper: API calls ---
|
||||||
|
api_get() {
|
||||||
|
wget -qO- --header="Authorization: Bearer $TOKEN" "${LOGTO_ENDPOINT}${1}" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
api_post() {
|
||||||
|
echo "$2" | wget -qO- --post-file=/dev/stdin \
|
||||||
|
--header="Authorization: Bearer $TOKEN" \
|
||||||
|
--header="Content-Type: application/json" \
|
||||||
|
"${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
api_delete() {
|
||||||
|
wget -qO- --method=DELETE \
|
||||||
|
--header="Authorization: Bearer $TOKEN" \
|
||||||
|
"${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Find or create SPA app ---
|
||||||
|
log "Checking for existing SPA app..."
|
||||||
|
EXISTING_APPS=$(api_get "/api/applications")
|
||||||
|
SPA_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$SPA_APP_NAME\" and .type == \"SPA\") | .id")
|
||||||
|
|
||||||
|
if [ -n "$SPA_ID" ]; then
|
||||||
|
log "SPA app already exists: $SPA_ID"
|
||||||
|
else
|
||||||
|
log "Creating SPA app..."
|
||||||
|
SPA_RESPONSE=$(api_post "/api/applications" "{
|
||||||
|
\"name\": \"$SPA_APP_NAME\",
|
||||||
|
\"type\": \"SPA\",
|
||||||
|
\"oidcClientMetadata\": {
|
||||||
|
\"redirectUris\": $REDIRECT_URIS,
|
||||||
|
\"postLogoutRedirectUris\": $POST_LOGOUT_URIS
|
||||||
|
}
|
||||||
|
}")
|
||||||
|
SPA_ID=$(echo "$SPA_RESPONSE" | jq -r '.id')
|
||||||
|
log "Created SPA app: $SPA_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Find or create M2M app ---
|
||||||
|
log "Checking for existing M2M app..."
|
||||||
|
M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id")
|
||||||
|
|
||||||
|
M2M_SECRET=""
|
||||||
|
if [ -n "$M2M_ID" ]; then
|
||||||
|
log "M2M app already exists: $M2M_ID"
|
||||||
|
M2M_SECRET=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \
|
||||||
|
"SELECT secret FROM applications WHERE id = '$M2M_ID' AND tenant_id = 'default';")
|
||||||
|
else
|
||||||
|
log "Creating M2M app..."
|
||||||
|
M2M_RESPONSE=$(api_post "/api/applications" "{
|
||||||
|
\"name\": \"$M2M_APP_NAME\",
|
||||||
|
\"type\": \"MachineToMachine\"
|
||||||
|
}")
|
||||||
|
M2M_ID=$(echo "$M2M_RESPONSE" | jq -r '.id')
|
||||||
|
M2M_SECRET=$(echo "$M2M_RESPONSE" | jq -r '.secret')
|
||||||
|
log "Created M2M app: $M2M_ID"
|
||||||
|
|
||||||
|
# Assign Management API role
|
||||||
|
log "Assigning Management API access..."
|
||||||
|
MGMT_RESOURCE_ID=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \
|
||||||
|
"SELECT id FROM resources WHERE indicator = '$MGMT_API_RESOURCE' AND tenant_id = 'default';")
|
||||||
|
|
||||||
|
if [ -n "$MGMT_RESOURCE_ID" ]; then
|
||||||
|
SCOPE_IDS=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \
|
||||||
|
"SELECT json_agg(id) FROM scopes WHERE resource_id = '$MGMT_RESOURCE_ID' AND tenant_id = 'default';" | tr -d '[:space:]')
|
||||||
|
|
||||||
|
ROLE_RESPONSE=$(api_post "/api/roles" "{
|
||||||
|
\"name\": \"cameleer-m2m-management\",
|
||||||
|
\"description\": \"Full Management API access for Cameleer SaaS\",
|
||||||
|
\"type\": \"MachineToMachine\",
|
||||||
|
\"scopeIds\": $SCOPE_IDS
|
||||||
|
}")
|
||||||
|
ROLE_ID=$(echo "$ROLE_RESPONSE" | jq -r '.id')
|
||||||
|
|
||||||
|
if [ -n "$ROLE_ID" ] && [ "$ROLE_ID" != "null" ]; then
|
||||||
|
api_post "/api/roles/$ROLE_ID/applications" "{\"applicationIds\": [\"$M2M_ID\"]}" >/dev/null
|
||||||
|
log "Assigned Management API role to M2M app."
|
||||||
|
|
||||||
|
# Verify our M2M app works
|
||||||
|
VERIFY=$(get_token "$M2M_ID" "$M2M_SECRET")
|
||||||
|
VERIFY_TOKEN=$(echo "$VERIFY" | jq -r '.access_token')
|
||||||
|
if [ -n "$VERIFY_TOKEN" ] && [ "$VERIFY_TOKEN" != "null" ]; then
|
||||||
|
log "Verified M2M app works."
|
||||||
|
else
|
||||||
|
log "WARNING: M2M app verification failed — skipping seeded app cleanup"
|
||||||
|
M2M_SECRET=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Find or create default user ---
|
||||||
|
log "Checking for existing user '$DEFAULT_USERNAME'..."
|
||||||
|
USER_ID=$(api_get "/api/users?search=$DEFAULT_USERNAME" | jq -r ".[] | select(.username == \"$DEFAULT_USERNAME\") | .id")
|
||||||
|
|
||||||
|
if [ -n "$USER_ID" ]; then
|
||||||
|
log "User '$DEFAULT_USERNAME' already exists: $USER_ID"
|
||||||
|
else
|
||||||
|
log "Creating user '$DEFAULT_USERNAME'..."
|
||||||
|
USER_RESPONSE=$(api_post "/api/users" "{
|
||||||
|
\"username\": \"$DEFAULT_USERNAME\",
|
||||||
|
\"password\": \"$DEFAULT_PASSWORD\",
|
||||||
|
\"name\": \"Cameleer Admin\"
|
||||||
|
}")
|
||||||
|
USER_ID=$(echo "$USER_RESPONSE" | jq -r '.id')
|
||||||
|
log "Created user: $USER_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Cleanup seeded M2M apps with known secrets ---
|
||||||
|
if [ -n "$M2M_SECRET" ]; then
|
||||||
|
log "Cleaning up seeded apps with known secrets..."
|
||||||
|
for SEEDED_ID in "m-default" "m-admin" "s6cz3wajdv8gtdyz8e941"; do
|
||||||
|
if echo "$EXISTING_APPS" | jq -e ".[] | select(.id == \"$SEEDED_ID\")" >/dev/null 2>&1; then
|
||||||
|
api_delete "/api/applications/$SEEDED_ID"
|
||||||
|
log "Deleted seeded app: $SEEDED_ID"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
log "Skipping seeded app cleanup (M2M secret not verified)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Write bootstrap results ---
|
||||||
|
log "Writing bootstrap config to $BOOTSTRAP_FILE..."
|
||||||
|
mkdir -p "$(dirname "$BOOTSTRAP_FILE")"
|
||||||
|
cat > "$BOOTSTRAP_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"spaClientId": "$SPA_ID",
|
||||||
|
"m2mClientId": "$M2M_ID",
|
||||||
|
"m2mClientSecret": "$M2M_SECRET",
|
||||||
|
"defaultUsername": "$DEFAULT_USERNAME"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log "Bootstrap complete!"
|
||||||
|
log " SPA Client ID: $SPA_ID"
|
||||||
|
log " M2M Client ID: $M2M_ID"
|
||||||
|
log " Default user: $DEFAULT_USERNAME / $DEFAULT_PASSWORD"
|
||||||
@@ -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
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/actuator/health").permitAll()
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
.requestMatchers("/auth/verify").permitAll()
|
.requestMatchers("/auth/verify").permitAll()
|
||||||
|
.requestMatchers("/api/config").permitAll()
|
||||||
.requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license").permitAll()
|
.requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license").permitAll()
|
||||||
.requestMatchers("/assets/**", "/favicon.ico").permitAll()
|
.requestMatchers("/assets/**", "/favicon.ico").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ cameleer:
|
|||||||
logto-endpoint: ${LOGTO_ENDPOINT:}
|
logto-endpoint: ${LOGTO_ENDPOINT:}
|
||||||
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
|
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
|
||||||
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
|
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
|
||||||
|
spa-client-id: ${LOGTO_SPA_CLIENT_ID:}
|
||||||
runtime:
|
runtime:
|
||||||
max-jar-size: 209715200
|
max-jar-size: 209715200
|
||||||
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
|
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useAuthStore } from './auth-store';
|
import { useAuthStore } from './auth-store';
|
||||||
import { Spinner } from '@cameleer/design-system';
|
import { Spinner } from '@cameleer/design-system';
|
||||||
|
import { fetchConfig } from '../config';
|
||||||
|
|
||||||
export function CallbackPage() {
|
export function CallbackPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -15,30 +16,30 @@ export function CallbackPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
|
|
||||||
const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
|
|
||||||
const redirectUri = `${window.location.origin}/callback`;
|
const redirectUri = `${window.location.origin}/callback`;
|
||||||
|
|
||||||
fetch(`${logtoEndpoint}/oidc/token`, {
|
fetchConfig().then((config) => {
|
||||||
method: 'POST',
|
fetch(`${config.logtoEndpoint}/oidc/token`, {
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
method: 'POST',
|
||||||
body: new URLSearchParams({
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
grant_type: 'authorization_code',
|
body: new URLSearchParams({
|
||||||
code,
|
grant_type: 'authorization_code',
|
||||||
client_id: clientId,
|
code,
|
||||||
redirect_uri: redirectUri,
|
client_id: config.logtoClientId,
|
||||||
}),
|
redirect_uri: redirectUri,
|
||||||
})
|
}),
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.access_token) {
|
|
||||||
login(data.access_token, data.refresh_token || '');
|
|
||||||
navigate('/');
|
|
||||||
} else {
|
|
||||||
navigate('/login');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => navigate('/login'));
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.access_token) {
|
||||||
|
login(data.access_token, data.refresh_token || '');
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => navigate('/login'));
|
||||||
|
});
|
||||||
}, [login, navigate]);
|
}, [login, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
import { Button } from '@cameleer/design-system';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Spinner } from '@cameleer/design-system';
|
||||||
|
import { fetchConfig } from '../config';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
|
const [config, setConfig] = useState<{ logtoEndpoint: string; logtoClientId: string } | null>(null);
|
||||||
const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
|
const [loading, setLoading] = useState(true);
|
||||||
const redirectUri = `${window.location.origin}/callback`;
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig().then((c) => {
|
||||||
|
setConfig(c);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogin = () => {
|
const handleLogin = () => {
|
||||||
|
if (!config?.logtoClientId) return;
|
||||||
|
const redirectUri = `${window.location.origin}/callback`;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: config.logtoClientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: 'openid profile email offline_access',
|
scope: 'openid profile email offline_access',
|
||||||
});
|
});
|
||||||
window.location.href = `${logtoEndpoint}/oidc/auth?${params}`;
|
window.location.href = `${config.logtoEndpoint}/oidc/auth?${params}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,7 +40,13 @@ export function LoginPage() {
|
|||||||
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
|
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
|
||||||
Managed Apache Camel Runtime
|
Managed Apache Camel Runtime
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleLogin}>Sign in with Logto</Button>
|
{config?.logtoClientId ? (
|
||||||
|
<Button onClick={handleLogin}>Sign in with Logto</Button>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
Identity provider not configured. Run the bootstrap script or check HOWTO.md.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
27
ui/src/config.ts
Normal file
27
ui/src/config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
interface AppConfig {
|
||||||
|
logtoEndpoint: string;
|
||||||
|
logtoClientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: AppConfig | null = null;
|
||||||
|
|
||||||
|
export async function fetchConfig(): Promise<AppConfig> {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config');
|
||||||
|
if (response.ok) {
|
||||||
|
cached = await response.json();
|
||||||
|
return cached!;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Config endpoint not available (e.g., Vite dev without backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to env vars (Vite dev mode)
|
||||||
|
cached = {
|
||||||
|
logtoEndpoint: import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001',
|
||||||
|
logtoClientId: import.meta.env.VITE_LOGTO_CLIENT_ID || '',
|
||||||
|
};
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user