feat: zero-config first-run experience with Logto bootstrap
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 37s

- 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:
hsiegeln
2026-04-05 00:22:22 +02:00
parent cda7dfbaa7
commit 021b056bce
9 changed files with 371 additions and 28 deletions

View File

@@ -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 (

View File

@@ -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>
);