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_M2M_CLIENT_ID=
|
||||
LOGTO_M2M_CLIENT_SECRET=
|
||||
LOGTO_SPA_CLIENT_ID=
|
||||
|
||||
# Ed25519 Keys (mount PEM files)
|
||||
CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key
|
||||
|
||||
@@ -42,6 +42,12 @@ services:
|
||||
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
|
||||
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
|
||||
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:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.logto.rule=PathPrefix(`/oidc`) || PathPrefix(`/interaction`)
|
||||
@@ -49,16 +55,38 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
logto-bootstrap:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./keys:/etc/cameleer/keys:ro
|
||||
- jardata:/data/jars
|
||||
- bootstrapdata:/data/bootstrap:ro
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||||
@@ -139,3 +167,4 @@ volumes:
|
||||
chdata:
|
||||
acme:
|
||||
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
|
||||
.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}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAuthStore } from './auth-store';
|
||||
import { Spinner } from '@cameleer/design-system';
|
||||
import { fetchConfig } from '../config';
|
||||
|
||||
export function CallbackPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -15,30 +16,30 @@ export function CallbackPage() {
|
||||
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`;
|
||||
|
||||
fetch(`${logtoEndpoint}/oidc/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.access_token) {
|
||||
login(data.access_token, data.refresh_token || '');
|
||||
navigate('/');
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
fetchConfig().then((config) => {
|
||||
fetch(`${config.logtoEndpoint}/oidc/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: config.logtoClientId,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
})
|
||||
.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]);
|
||||
|
||||
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() {
|
||||
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 [config, setConfig] = useState<{ logtoEndpoint: string; logtoClientId: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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 = () => {
|
||||
if (!config?.logtoClientId) return;
|
||||
const redirectUri = `${window.location.origin}/callback`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_id: config.logtoClientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email offline_access',
|
||||
});
|
||||
window.location.href = `${logtoEndpoint}/oidc/auth?${params}`;
|
||||
window.location.href = `${config.logtoEndpoint}/oidc/auth?${params}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -22,7 +40,13 @@ export function LoginPage() {
|
||||
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
|
||||
Managed Apache Camel Runtime
|
||||
</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>
|
||||
);
|
||||
|
||||
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